@opentabs-dev/opentabs-plugin-ynab 0.0.85 → 0.0.87

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.
Files changed (92) hide show
  1. package/dist/adapter.iife.js +1315 -545
  2. package/dist/adapter.iife.js.map +4 -4
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +23 -16
  6. package/dist/index.js.map +1 -1
  7. package/dist/tools/create-category-group.d.ts +11 -0
  8. package/dist/tools/create-category-group.d.ts.map +1 -0
  9. package/dist/tools/create-category-group.js +37 -0
  10. package/dist/tools/create-category-group.js.map +1 -0
  11. package/dist/tools/create-category.d.ts +59 -0
  12. package/dist/tools/create-category.d.ts.map +1 -0
  13. package/dist/tools/create-category.js +63 -0
  14. package/dist/tools/create-category.js.map +1 -0
  15. package/dist/tools/create-transaction.d.ts +2 -2
  16. package/dist/tools/create-transaction.d.ts.map +1 -1
  17. package/dist/tools/create-transaction.js +63 -22
  18. package/dist/tools/create-transaction.js.map +1 -1
  19. package/dist/tools/delete-category-group.d.ts +8 -0
  20. package/dist/tools/delete-category-group.d.ts.map +1 -0
  21. package/dist/tools/delete-category-group.js +33 -0
  22. package/dist/tools/delete-category-group.js.map +1 -0
  23. package/dist/tools/delete-category.d.ts +7 -0
  24. package/dist/tools/delete-category.d.ts.map +1 -0
  25. package/dist/tools/delete-category.js +28 -0
  26. package/dist/tools/delete-category.js.map +1 -0
  27. package/dist/tools/delete-transaction.d.ts.map +1 -1
  28. package/dist/tools/delete-transaction.js +22 -7
  29. package/dist/tools/delete-transaction.js.map +1 -1
  30. package/dist/tools/get-account.d.ts.map +1 -1
  31. package/dist/tools/get-account.js +3 -3
  32. package/dist/tools/get-account.js.map +1 -1
  33. package/dist/tools/get-month.d.ts +2 -1
  34. package/dist/tools/get-month.d.ts.map +1 -1
  35. package/dist/tools/get-month.js +10 -33
  36. package/dist/tools/get-month.js.map +1 -1
  37. package/dist/tools/get-plan.d.ts.map +1 -1
  38. package/dist/tools/get-plan.js +12 -5
  39. package/dist/tools/get-plan.js.map +1 -1
  40. package/dist/tools/get-transaction.d.ts +2 -2
  41. package/dist/tools/get-transaction.d.ts.map +1 -1
  42. package/dist/tools/get-transaction.js +5 -4
  43. package/dist/tools/get-transaction.js.map +1 -1
  44. package/dist/tools/list-accounts.d.ts.map +1 -1
  45. package/dist/tools/list-accounts.js +4 -4
  46. package/dist/tools/list-accounts.js.map +1 -1
  47. package/dist/tools/list-categories.d.ts.map +1 -1
  48. package/dist/tools/list-categories.js +9 -29
  49. package/dist/tools/list-categories.js.map +1 -1
  50. package/dist/tools/list-months.d.ts +1 -1
  51. package/dist/tools/list-months.d.ts.map +1 -1
  52. package/dist/tools/list-months.js +5 -14
  53. package/dist/tools/list-months.js.map +1 -1
  54. package/dist/tools/list-payees.d.ts.map +1 -1
  55. package/dist/tools/list-payees.js +3 -3
  56. package/dist/tools/list-payees.js.map +1 -1
  57. package/dist/tools/list-scheduled-transactions.d.ts.map +1 -1
  58. package/dist/tools/list-scheduled-transactions.js +7 -5
  59. package/dist/tools/list-scheduled-transactions.js.map +1 -1
  60. package/dist/tools/list-transactions.d.ts +4 -2
  61. package/dist/tools/list-transactions.d.ts.map +1 -1
  62. package/dist/tools/list-transactions.js +40 -8
  63. package/dist/tools/list-transactions.js.map +1 -1
  64. package/dist/tools/move-category-budget.d.ts +24 -0
  65. package/dist/tools/move-category-budget.d.ts.map +1 -0
  66. package/dist/tools/move-category-budget.js +100 -0
  67. package/dist/tools/move-category-budget.js.map +1 -0
  68. package/dist/tools/schemas.d.ts +185 -28
  69. package/dist/tools/schemas.d.ts.map +1 -1
  70. package/dist/tools/schemas.js +411 -28
  71. package/dist/tools/schemas.js.map +1 -1
  72. package/dist/tools/snooze-category-goal.d.ts +10 -0
  73. package/dist/tools/snooze-category-goal.d.ts.map +1 -0
  74. package/dist/tools/snooze-category-goal.js +53 -0
  75. package/dist/tools/snooze-category-goal.js.map +1 -0
  76. package/dist/tools/update-category-budget.d.ts.map +1 -1
  77. package/dist/tools/update-category-budget.js +51 -26
  78. package/dist/tools/update-category-budget.js.map +1 -1
  79. package/dist/tools/update-category.d.ts +61 -0
  80. package/dist/tools/update-category.d.ts.map +1 -0
  81. package/dist/tools/update-category.js +46 -0
  82. package/dist/tools/update-category.js.map +1 -0
  83. package/dist/tools/update-transaction.d.ts +3 -2
  84. package/dist/tools/update-transaction.d.ts.map +1 -1
  85. package/dist/tools/update-transaction.js +84 -31
  86. package/dist/tools/update-transaction.js.map +1 -1
  87. package/dist/tools.json +965 -43
  88. package/dist/ynab-api.d.ts +4 -1
  89. package/dist/ynab-api.d.ts.map +1 -1
  90. package/dist/ynab-api.js +47 -26
  91. package/dist/ynab-api.js.map +1 -1
  92. package/package.json +3 -3
@@ -329,162 +329,6 @@
329
329
  configSchema;
330
330
  };
331
331
 
332
- // src/ynab-api.ts
333
- var generateDeviceId = () => crypto.randomUUID();
334
- var extractPlanId = () => {
335
- const url2 = getCurrentUrl();
336
- const match = url2.match(/app\.ynab\.com\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
337
- return match?.[1] ?? null;
338
- };
339
- var getAuth = () => {
340
- const cached2 = getAuthCache("ynab");
341
- if (cached2?.sessionToken && cached2.planId) return cached2;
342
- const sessionToken = getMetaContent("session-token");
343
- if (!sessionToken) return null;
344
- const user = getPageGlobal("YNAB_CLIENT_CONSTANTS.USER");
345
- if (!user?.id) return null;
346
- const planId = extractPlanId();
347
- if (!planId) return null;
348
- const deviceId = cached2?.deviceId ?? generateDeviceId();
349
- const auth = {
350
- sessionToken,
351
- deviceId,
352
- userId: user.id,
353
- planId
354
- };
355
- setAuthCache("ynab", auth);
356
- return auth;
357
- };
358
- var isAuthenticated = () => getAuth() !== null;
359
- var waitForAuth = () => waitUntil(() => isAuthenticated(), { interval: 500, timeout: 5e3 }).then(
360
- () => true,
361
- () => false
362
- );
363
- var getPlanId = () => {
364
- const auth = getAuth();
365
- if (!auth) throw ToolError.auth("Not authenticated \u2014 please log in to YNAB.");
366
- return auth.planId;
367
- };
368
- var getHeaders = () => {
369
- const auth = getAuth();
370
- if (!auth) throw ToolError.auth("Not authenticated \u2014 please log in to YNAB.");
371
- const appVersion = getPageGlobal("YNAB_CLIENT_CONSTANTS.YNAB_APP_VERSION");
372
- const headers = {
373
- "X-Session-Token": auth.sessionToken,
374
- "X-YNAB-Device-Id": auth.deviceId,
375
- "X-YNAB-Device-OS": "web",
376
- "X-Requested-With": "XMLHttpRequest",
377
- Accept: "application/json, text/javascript, */*; q=0.01"
378
- };
379
- if (appVersion) headers["X-YNAB-Device-App-Version"] = appVersion;
380
- return headers;
381
- };
382
- var handleApiError = async (response, context) => {
383
- const errorBody = (await response.text().catch(() => "")).substring(0, 512);
384
- if (response.status === 426) {
385
- clearAuthCache("ynab");
386
- throw ToolError.auth(
387
- "YNAB requires an app update (426). The session has been cleared \u2014 please reload the YNAB tab and try again."
388
- );
389
- }
390
- if (response.status === 429) {
391
- const retryAfter = response.headers.get("Retry-After");
392
- const retryMs = retryAfter !== null ? parseRetryAfterMs(retryAfter) : void 0;
393
- throw ToolError.rateLimited(`Rate limited: ${context} \u2014 ${errorBody}`, retryMs);
394
- }
395
- if (response.status === 401 || response.status === 403) {
396
- clearAuthCache("ynab");
397
- throw ToolError.auth(`Auth error (${response.status}): ${errorBody}`);
398
- }
399
- if (response.status === 404) throw ToolError.notFound(`Not found: ${context} \u2014 ${errorBody}`);
400
- if (response.status === 422) throw ToolError.validation(`Validation error: ${context} \u2014 ${errorBody}`);
401
- throw ToolError.internal(`API error (${response.status}): ${context} \u2014 ${errorBody}`);
402
- };
403
- var catalog = async (operationName, requestData = {}) => {
404
- const headers = getHeaders();
405
- headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8";
406
- let response;
407
- try {
408
- response = await fetch("/api/v1/catalog", {
409
- method: "POST",
410
- headers,
411
- credentials: "include",
412
- body: `operation_name=${encodeURIComponent(operationName)}&request_data=${encodeURIComponent(JSON.stringify(requestData))}`,
413
- signal: AbortSignal.timeout(3e4)
414
- });
415
- } catch (err2) {
416
- if (err2 instanceof DOMException && err2.name === "TimeoutError")
417
- throw ToolError.timeout(`Catalog request timed out: ${operationName}`);
418
- if (err2 instanceof DOMException && err2.name === "AbortError") throw new ToolError("Request was aborted", "aborted");
419
- throw new ToolError(`Network error: ${err2 instanceof Error ? err2.message : String(err2)}`, "network_error", {
420
- category: "internal",
421
- retryable: true
422
- });
423
- }
424
- if (!response.ok) return handleApiError(response, operationName);
425
- const data = await response.json();
426
- if (data.error) {
427
- throw ToolError.internal(`Catalog error (${operationName}): ${data.error.message}`);
428
- }
429
- return data;
430
- };
431
- var BUDGET_SCHEMA_VERSION = 41;
432
- var syncBudget = async (planId) => catalog("syncBudgetData", {
433
- budget_version_id: planId,
434
- sync_type: "delta",
435
- starting_device_knowledge: 0,
436
- ending_device_knowledge: 0,
437
- device_knowledge_of_server: 0,
438
- calculated_entities_included: false,
439
- schema_version: BUDGET_SCHEMA_VERSION,
440
- schema_version_of_knowledge: BUDGET_SCHEMA_VERSION,
441
- changed_entities: {}
442
- });
443
- var syncWrite = async (planId, changedEntities) => {
444
- const readResult = await syncBudget(planId);
445
- const serverKnowledge = readResult.current_server_knowledge ?? 0;
446
- return catalog("syncBudgetData", {
447
- budget_version_id: planId,
448
- sync_type: "delta",
449
- starting_device_knowledge: 0,
450
- ending_device_knowledge: 1,
451
- device_knowledge_of_server: serverKnowledge,
452
- calculated_entities_included: false,
453
- schema_version: BUDGET_SCHEMA_VERSION,
454
- schema_version_of_knowledge: BUDGET_SCHEMA_VERSION,
455
- changed_entities: changedEntities
456
- });
457
- };
458
- var api = async (endpoint, options = {}) => {
459
- const headers = getHeaders();
460
- let fetchBody;
461
- if (options.body) {
462
- headers["Content-Type"] = "application/json";
463
- fetchBody = JSON.stringify(options.body);
464
- }
465
- let response;
466
- try {
467
- response = await fetch(`/api/v2${endpoint}`, {
468
- method: options.method ?? "GET",
469
- headers,
470
- body: fetchBody,
471
- credentials: "include",
472
- signal: AbortSignal.timeout(3e4)
473
- });
474
- } catch (err2) {
475
- if (err2 instanceof DOMException && err2.name === "TimeoutError")
476
- throw ToolError.timeout(`API request timed out: ${endpoint}`);
477
- if (err2 instanceof DOMException && err2.name === "AbortError") throw new ToolError("Request was aborted", "aborted");
478
- throw new ToolError(`Network error: ${err2 instanceof Error ? err2.message : String(err2)}`, "network_error", {
479
- category: "internal",
480
- retryable: true
481
- });
482
- }
483
- if (!response.ok) return handleApiError(response, endpoint);
484
- if (response.status === 204) return {};
485
- return await response.json();
486
- };
487
-
488
332
  // node_modules/zod/v4/classic/external.js
489
333
  var external_exports = {};
490
334
  __export(external_exports, {
@@ -14253,19 +14097,214 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14253
14097
  // node_modules/zod/v4/classic/external.js
14254
14098
  config(en_default());
14255
14099
 
14256
- // src/tools/schemas.ts
14257
- var formatMilliunits = (milliunits) => {
14258
- const amount = milliunits / 1e3;
14259
- return amount.toFixed(2);
14100
+ // src/ynab-api.ts
14101
+ var NOT_AUTHENTICATED_MESSAGE = "Not authenticated \u2014 please log in to YNAB.";
14102
+ var generateDeviceId = () => crypto.randomUUID();
14103
+ var extractPlanId = () => {
14104
+ const url2 = getCurrentUrl();
14105
+ const match = url2.match(/app\.ynab\.com\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/);
14106
+ return match?.[1] ?? null;
14260
14107
  };
14261
- var userSchema = external_exports.object({
14262
- id: external_exports.string().describe("User ID"),
14263
- first_name: external_exports.string().describe("First name"),
14264
- email: external_exports.string().describe("Email address")
14108
+ var getAuth = () => {
14109
+ const cached2 = getAuthCache("ynab");
14110
+ if (cached2?.sessionToken && cached2.planId) return cached2;
14111
+ const sessionToken = getMetaContent("session-token");
14112
+ if (!sessionToken) return null;
14113
+ const user = getPageGlobal("YNAB_CLIENT_CONSTANTS.USER");
14114
+ if (!user?.id) return null;
14115
+ const planId = extractPlanId();
14116
+ if (!planId) return null;
14117
+ const deviceId = cached2?.deviceId ?? generateDeviceId();
14118
+ const auth = {
14119
+ sessionToken,
14120
+ deviceId,
14121
+ userId: user.id,
14122
+ planId
14123
+ };
14124
+ setAuthCache("ynab", auth);
14125
+ return auth;
14126
+ };
14127
+ var isAuthenticated = () => getAuth() !== null;
14128
+ var waitForAuth = () => waitUntil(() => isAuthenticated(), { interval: 500, timeout: 5e3 }).then(
14129
+ () => true,
14130
+ () => false
14131
+ );
14132
+ var getPlanId = () => {
14133
+ const auth = getAuth();
14134
+ if (!auth) throw ToolError.auth(NOT_AUTHENTICATED_MESSAGE);
14135
+ return auth.planId;
14136
+ };
14137
+ var getDeviceId = () => {
14138
+ const auth = getAuth();
14139
+ if (!auth) throw ToolError.auth(NOT_AUTHENTICATED_MESSAGE);
14140
+ return auth.deviceId;
14141
+ };
14142
+ var getUserId = () => {
14143
+ const auth = getAuth();
14144
+ if (!auth) throw ToolError.auth(NOT_AUTHENTICATED_MESSAGE);
14145
+ return auth.userId;
14146
+ };
14147
+ var assertAuthenticated = () => {
14148
+ if (!getAuth()) throw ToolError.auth(NOT_AUTHENTICATED_MESSAGE);
14149
+ };
14150
+ var getHeaders = () => {
14151
+ const auth = getAuth();
14152
+ if (!auth) throw ToolError.auth(NOT_AUTHENTICATED_MESSAGE);
14153
+ const appVersion = getPageGlobal("YNAB_CLIENT_CONSTANTS.YNAB_APP_VERSION");
14154
+ const headers = {
14155
+ "X-Session-Token": auth.sessionToken,
14156
+ "X-YNAB-Device-Id": auth.deviceId,
14157
+ "X-YNAB-Device-OS": "web",
14158
+ "X-Requested-With": "XMLHttpRequest",
14159
+ Accept: "application/json, text/javascript, */*; q=0.01"
14160
+ };
14161
+ if (appVersion) headers["X-YNAB-Device-App-Version"] = appVersion;
14162
+ return headers;
14163
+ };
14164
+ var handleApiError = async (response, context) => {
14165
+ const errorBody = (await response.text().catch(() => "")).substring(0, 512);
14166
+ if (response.status === 426) {
14167
+ clearAuthCache("ynab");
14168
+ throw ToolError.auth(
14169
+ "YNAB requires an app update (426). The session has been cleared \u2014 please reload the YNAB tab and try again."
14170
+ );
14171
+ }
14172
+ if (response.status === 429) {
14173
+ const retryAfter = response.headers.get("Retry-After");
14174
+ const retryMs = retryAfter !== null ? parseRetryAfterMs(retryAfter) : void 0;
14175
+ throw ToolError.rateLimited(`Rate limited: ${context} \u2014 ${errorBody}`, retryMs);
14176
+ }
14177
+ if (response.status === 401 || response.status === 403) {
14178
+ clearAuthCache("ynab");
14179
+ throw ToolError.auth(`Auth error (${response.status}): ${errorBody}`);
14180
+ }
14181
+ if (response.status === 404) throw ToolError.notFound(`Not found: ${context} \u2014 ${errorBody}`);
14182
+ if (response.status === 422) throw ToolError.validation(`Validation error: ${context} \u2014 ${errorBody}`);
14183
+ throw ToolError.internal(`API error (${response.status}): ${context} \u2014 ${errorBody}`);
14184
+ };
14185
+ var handleNetworkError = (err2, context) => {
14186
+ if (err2 instanceof DOMException && err2.name === "TimeoutError")
14187
+ throw ToolError.timeout(`Request timed out: ${context}`);
14188
+ if (err2 instanceof DOMException && err2.name === "AbortError") throw new ToolError("Request was aborted", "aborted");
14189
+ throw new ToolError(`Network error: ${err2 instanceof Error ? err2.message : String(err2)}`, "network_error", {
14190
+ category: "internal",
14191
+ retryable: true
14192
+ });
14193
+ };
14194
+ var catalog = async (operationName, requestData = {}) => {
14195
+ const headers = getHeaders();
14196
+ headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8";
14197
+ let response;
14198
+ try {
14199
+ response = await fetch("/api/v1/catalog", {
14200
+ method: "POST",
14201
+ headers,
14202
+ credentials: "include",
14203
+ body: `operation_name=${encodeURIComponent(operationName)}&request_data=${encodeURIComponent(JSON.stringify(requestData))}`,
14204
+ signal: AbortSignal.timeout(3e4)
14205
+ });
14206
+ } catch (err2) {
14207
+ return handleNetworkError(err2, operationName);
14208
+ }
14209
+ if (!response.ok) return handleApiError(response, operationName);
14210
+ const data = await response.json();
14211
+ if (data.error) {
14212
+ throw ToolError.internal(`Catalog error (${operationName}): ${data.error.message}`);
14213
+ }
14214
+ return data;
14215
+ };
14216
+ var BUDGET_SCHEMA_VERSION = 41;
14217
+ var syncBudget = async (planId) => catalog("syncBudgetData", {
14218
+ budget_version_id: planId,
14219
+ sync_type: "delta",
14220
+ starting_device_knowledge: 0,
14221
+ ending_device_knowledge: 0,
14222
+ device_knowledge_of_server: 0,
14223
+ calculated_entities_included: false,
14224
+ schema_version: BUDGET_SCHEMA_VERSION,
14225
+ schema_version_of_knowledge: BUDGET_SCHEMA_VERSION,
14226
+ changed_entities: {}
14265
14227
  });
14266
- var planSchema = external_exports.object({
14267
- id: external_exports.string().describe("Plan (budget version) ID used in API calls"),
14268
- budget_id: external_exports.string().describe("Underlying budget ID"),
14228
+ var syncWrite = async (planId, changedEntities, serverKnowledge) => {
14229
+ const knowledge = serverKnowledge ?? (await syncBudget(planId)).current_server_knowledge ?? 0;
14230
+ return catalog("syncBudgetData", {
14231
+ budget_version_id: planId,
14232
+ sync_type: "delta",
14233
+ starting_device_knowledge: 0,
14234
+ ending_device_knowledge: 1,
14235
+ device_knowledge_of_server: knowledge,
14236
+ calculated_entities_included: false,
14237
+ schema_version: BUDGET_SCHEMA_VERSION,
14238
+ schema_version_of_knowledge: BUDGET_SCHEMA_VERSION,
14239
+ changed_entities: changedEntities
14240
+ });
14241
+ };
14242
+ var api = async (endpoint, options = {}) => {
14243
+ const headers = getHeaders();
14244
+ let fetchBody;
14245
+ if (options.body) {
14246
+ headers["Content-Type"] = "application/json";
14247
+ fetchBody = JSON.stringify(options.body);
14248
+ }
14249
+ let response;
14250
+ try {
14251
+ response = await fetch(`/api/v2${endpoint}`, {
14252
+ method: options.method ?? "GET",
14253
+ headers,
14254
+ body: fetchBody,
14255
+ credentials: "include",
14256
+ signal: AbortSignal.timeout(3e4)
14257
+ });
14258
+ } catch (err2) {
14259
+ return handleNetworkError(err2, endpoint);
14260
+ }
14261
+ if (!response.ok) return handleApiError(response, endpoint);
14262
+ if (response.status === 204) return {};
14263
+ return await response.json();
14264
+ };
14265
+
14266
+ // src/tools/schemas.ts
14267
+ var formatMilliunits = (milliunits) => {
14268
+ const amount = milliunits / 1e3;
14269
+ return amount.toFixed(2);
14270
+ };
14271
+ var toMilliunits = (amount) => Math.round(amount * 1e3);
14272
+ var notTombstone = (x) => !x.is_tombstone;
14273
+ var findCategory = (entities, id) => {
14274
+ const c = (entities?.be_subcategories ?? []).find((s) => s.id === id && notTombstone(s));
14275
+ if (!c) throw ToolError.notFound(`Category not found: ${id}`);
14276
+ return c;
14277
+ };
14278
+ var findCategoryGroup = (entities, id) => {
14279
+ const g = (entities?.be_master_categories ?? []).find((m) => m.id === id && notTombstone(m));
14280
+ if (!g) throw ToolError.notFound(`Category group not found: ${id}`);
14281
+ return g;
14282
+ };
14283
+ var assertCategoryGroupDeletable = (group) => {
14284
+ if (group.deletable !== true) {
14285
+ throw ToolError.validation(`Category group "${group.name}" is not deletable.`);
14286
+ }
14287
+ };
14288
+ var assertCategoryDeletable = (category) => {
14289
+ if (category.entities_account_id != null || category.type !== CATEGORY_TYPE_DEFAULT) {
14290
+ throw ToolError.validation(`Category "${category.name}" is system-managed and cannot be deleted.`);
14291
+ }
14292
+ };
14293
+ var nextTopSortableIndex = (rows, step = 10) => {
14294
+ let min = 0;
14295
+ for (const r of rows) {
14296
+ if (typeof r.sortable_index === "number" && r.sortable_index < min) min = r.sortable_index;
14297
+ }
14298
+ return min - step;
14299
+ };
14300
+ var userSchema = external_exports.object({
14301
+ id: external_exports.string().describe("User ID"),
14302
+ first_name: external_exports.string().describe("First name"),
14303
+ email: external_exports.string().describe("Email address")
14304
+ });
14305
+ var planSchema = external_exports.object({
14306
+ id: external_exports.string().describe("Plan (budget version) ID used in API calls"),
14307
+ budget_id: external_exports.string().describe("Underlying budget ID"),
14269
14308
  name: external_exports.string().describe("Plan name"),
14270
14309
  date_format: external_exports.string().describe("Date format string (e.g. MM/DD/YYYY)"),
14271
14310
  currency_symbol: external_exports.string().describe("Currency symbol (e.g. $)"),
@@ -14317,7 +14356,6 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14317
14356
  cleared: external_exports.string().describe("Cleared status: cleared, uncleared, or reconciled"),
14318
14357
  approved: external_exports.boolean().describe("Whether the transaction is approved"),
14319
14358
  flag_color: external_exports.string().describe("Flag color or empty string"),
14320
- flag_name: external_exports.string().describe("Custom flag name or empty string"),
14321
14359
  account_id: external_exports.string().describe("Account ID"),
14322
14360
  account_name: external_exports.string().describe("Account name"),
14323
14361
  payee_id: external_exports.string().describe("Payee ID"),
@@ -14325,7 +14363,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14325
14363
  category_id: external_exports.string().describe("Category ID"),
14326
14364
  category_name: external_exports.string().describe("Category name"),
14327
14365
  transfer_account_id: external_exports.string().describe("If a transfer, the destination account ID"),
14328
- import_id: external_exports.string().describe("Import ID for deduplication"),
14366
+ imported_payee: external_exports.string().describe("Bank-imported payee name after YNAB cleansing (empty if manually entered)"),
14367
+ original_imported_payee: external_exports.string().describe("Raw payee string from the bank feed before any cleansing (empty if manually entered)"),
14329
14368
  deleted: external_exports.boolean().describe("Whether the transaction is deleted")
14330
14369
  });
14331
14370
  var subtransactionSchema = external_exports.object({
@@ -14351,7 +14390,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14351
14390
  budgeted_milliunits: external_exports.number().describe("Total budgeted in milliunits"),
14352
14391
  activity_milliunits: external_exports.number().describe("Total activity in milliunits"),
14353
14392
  to_be_budgeted_milliunits: external_exports.number().describe("Ready to Assign in milliunits"),
14354
- age_of_money: external_exports.number().describe("Age of money in days")
14393
+ age_of_money: external_exports.number().nullable().describe("Age of money in days, or null if not yet computed")
14355
14394
  });
14356
14395
  var scheduledTransactionSchema = external_exports.object({
14357
14396
  id: external_exports.string().describe("Scheduled transaction ID"),
@@ -14372,6 +14411,256 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14372
14411
  category_name: external_exports.string().describe("Category name"),
14373
14412
  deleted: external_exports.boolean().describe("Whether the scheduled transaction is deleted")
14374
14413
  });
14414
+ var MONEY_MOVEMENT_SOURCE = {
14415
+ /** RTA ↔ category (in either direction). */
14416
+ ASSIGN: "manual_assign",
14417
+ /** Category-to-category transfer. */
14418
+ MOVEMENT: "manual_movement"
14419
+ };
14420
+ var GOAL_TYPE = {
14421
+ /** "Set aside" or "Refill" — `goal_needs_whole_amount` differentiates. */
14422
+ NEED: "NEED",
14423
+ /** "Have a balance of" — no date, no cadence. */
14424
+ TARGET_BALANCE: "TB",
14425
+ /** "Have a balance of by date" — one-shot with `goal_target_date`. */
14426
+ TARGET_BY_DATE: "TBD",
14427
+ /** Debt payment goals on debt-account categories. */
14428
+ DEBT: "DEBT",
14429
+ /** Legacy "Monthly Funding". The modern UI no longer creates these but they
14430
+ still exist on older categories and the API still honors them. */
14431
+ MONTHLY_FUNDING: "MF"
14432
+ };
14433
+ var CATEGORY_TYPE_DEFAULT = "DFT";
14434
+ var SUBCATEGORY_BUDGET_PREFIX = "mcb";
14435
+ var MONTHLY_BUDGET_PREFIX = "mb";
14436
+ var toMonthKey = (month) => month.substring(0, 7);
14437
+ var currentMonthKey = () => {
14438
+ const now = /* @__PURE__ */ new Date();
14439
+ return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
14440
+ };
14441
+ var formatSubcategoryBudgetId = (monthKey, categoryId) => `${SUBCATEGORY_BUDGET_PREFIX}/${monthKey}/${categoryId}`;
14442
+ var formatMonthlyBudgetId = (monthKey, planId) => `${MONTHLY_BUDGET_PREFIX}/${monthKey}/${planId}`;
14443
+ var CLEARED_MAP = {
14444
+ cleared: "Cleared",
14445
+ uncleared: "Uncleared",
14446
+ reconciled: "Reconciled"
14447
+ };
14448
+ var FLAG_MAP = {
14449
+ red: "Red",
14450
+ orange: "Orange",
14451
+ yellow: "Yellow",
14452
+ green: "Green",
14453
+ blue: "Blue",
14454
+ purple: "Purple"
14455
+ };
14456
+ var cadenceWireValue = {
14457
+ weekly: 2,
14458
+ monthly: 1,
14459
+ yearly: 13
14460
+ };
14461
+ var isValidCalendarDate = (s) => {
14462
+ const parts = s.split("-").map(Number);
14463
+ const year = parts[0] ?? 0;
14464
+ const month = parts[1] ?? 0;
14465
+ const day = parts[2] ?? 0;
14466
+ if (month < 1 || month > 12) return false;
14467
+ const maxDay = new Date(year, month, 0).getDate();
14468
+ return day >= 1 && day <= maxDay;
14469
+ };
14470
+ var needGoalShape = {
14471
+ target: external_exports.number().positive().describe("Goal amount in currency units (e.g. 50 for $50)"),
14472
+ cadence: external_exports.enum(["weekly", "monthly", "yearly"]).optional().describe("How often the goal recurs. Defaults to monthly."),
14473
+ every: external_exports.number().int().min(1).optional().describe("Multiplier on cadence (e.g. cadence=monthly + every=5 means every 5 months). Defaults to 1."),
14474
+ day: external_exports.number().int().min(0).max(31).optional().describe("Day-of-week (0=Sunday, 6=Saturday) for weekly cadence, or day-of-month (1-31) for monthly cadence."),
14475
+ start_date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD").refine(isValidCalendarDate, "Date must be a valid calendar date").optional().describe("First occurrence date (YYYY-MM-DD). Required for yearly cadence; optional for others.")
14476
+ };
14477
+ var yearlyNeedRefine = (data, ctx) => {
14478
+ if (data.cadence === "yearly" && !data.start_date) {
14479
+ ctx.addIssue({
14480
+ code: external_exports.ZodIssueCode.custom,
14481
+ message: 'start_date is required when cadence is "yearly"',
14482
+ path: ["start_date"]
14483
+ });
14484
+ }
14485
+ };
14486
+ var needCadenceDayRefine = (data, ctx) => {
14487
+ if (data.day === void 0) return;
14488
+ const cadence = data.cadence ?? "monthly";
14489
+ if (cadence === "weekly" && (data.day < 0 || data.day > 6)) {
14490
+ ctx.addIssue({
14491
+ code: external_exports.ZodIssueCode.custom,
14492
+ message: "For weekly cadence, day must be 0\u20136 (0=Sunday, 6=Saturday)",
14493
+ path: ["day"]
14494
+ });
14495
+ } else if (cadence === "monthly" && (data.day < 1 || data.day > 31)) {
14496
+ ctx.addIssue({
14497
+ code: external_exports.ZodIssueCode.custom,
14498
+ message: "For monthly cadence, day must be 1\u201331",
14499
+ path: ["day"]
14500
+ });
14501
+ } else if (cadence === "yearly") {
14502
+ ctx.addIssue({
14503
+ code: external_exports.ZodIssueCode.custom,
14504
+ message: "For yearly cadence, use start_date to set the recurrence anchor instead of day",
14505
+ path: ["day"]
14506
+ });
14507
+ }
14508
+ };
14509
+ var goalSpecSchema = external_exports.discriminatedUnion("type", [
14510
+ external_exports.object({ type: external_exports.literal("set_aside"), ...needGoalShape }).strict().superRefine(yearlyNeedRefine).superRefine(needCadenceDayRefine),
14511
+ external_exports.object({ type: external_exports.literal("refill"), ...needGoalShape }).strict().superRefine(yearlyNeedRefine).superRefine(needCadenceDayRefine),
14512
+ external_exports.object({
14513
+ type: external_exports.literal("target_balance"),
14514
+ target: external_exports.number().positive().describe("Balance to maintain in currency units")
14515
+ }).strict(),
14516
+ external_exports.object({
14517
+ type: external_exports.literal("target_by_date"),
14518
+ target: external_exports.number().positive().describe("Target balance to have by the given date in currency units"),
14519
+ date: external_exports.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be YYYY-MM-DD").refine(isValidCalendarDate, "Date must be a valid calendar date").describe("Target date YYYY-MM-DD")
14520
+ }).strict(),
14521
+ external_exports.object({
14522
+ type: external_exports.literal("debt"),
14523
+ target: external_exports.number().positive().describe("Monthly payment amount in currency units"),
14524
+ day: external_exports.number().int().min(1).max(31).optional().describe("Day of month the payment is due (1-31). Defaults to 1.")
14525
+ }).strict(),
14526
+ external_exports.object({ type: external_exports.literal("none") }).strict()
14527
+ ]);
14528
+ var NO_GOAL_FIELDS = {
14529
+ goal_type: null,
14530
+ goal_created_on: null,
14531
+ goal_needs_whole_amount: null,
14532
+ goal_target_amount: 0,
14533
+ goal_target_date: null,
14534
+ goal_cadence: null,
14535
+ goal_cadence_frequency: null,
14536
+ goal_day: null
14537
+ };
14538
+ var buildGoalFields = (goal) => {
14539
+ if (!goal || goal.type === "none") return { ...NO_GOAL_FIELDS };
14540
+ const now = /* @__PURE__ */ new Date();
14541
+ const createdOn = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
14542
+ const base = { ...NO_GOAL_FIELDS, goal_created_on: createdOn };
14543
+ switch (goal.type) {
14544
+ case "set_aside":
14545
+ case "refill": {
14546
+ const cadence = goal.cadence ?? "monthly";
14547
+ return {
14548
+ ...base,
14549
+ goal_type: GOAL_TYPE.NEED,
14550
+ goal_needs_whole_amount: goal.type === "set_aside",
14551
+ goal_target_amount: toMilliunits(goal.target),
14552
+ goal_cadence: cadenceWireValue[cadence],
14553
+ goal_cadence_frequency: goal.every ?? 1,
14554
+ goal_day: cadence === "yearly" ? null : goal.day ?? null,
14555
+ goal_target_date: goal.start_date ?? null
14556
+ };
14557
+ }
14558
+ case "target_balance":
14559
+ return { ...base, goal_type: GOAL_TYPE.TARGET_BALANCE, goal_target_amount: toMilliunits(goal.target) };
14560
+ case "target_by_date":
14561
+ return {
14562
+ ...base,
14563
+ goal_type: GOAL_TYPE.TARGET_BY_DATE,
14564
+ goal_target_amount: toMilliunits(goal.target),
14565
+ goal_target_date: goal.date
14566
+ };
14567
+ case "debt":
14568
+ return {
14569
+ ...base,
14570
+ goal_type: GOAL_TYPE.DEBT,
14571
+ goal_target_amount: toMilliunits(goal.target),
14572
+ goal_cadence: cadenceWireValue.monthly,
14573
+ goal_cadence_frequency: 1,
14574
+ goal_day: goal.day ?? 1
14575
+ };
14576
+ }
14577
+ };
14578
+ var resolvePayee = (existingPayees, payeeName) => {
14579
+ const target = payeeName.toLowerCase();
14580
+ const match = existingPayees.find((p) => notTombstone(p) && p.name?.toLowerCase() === target);
14581
+ if (match?.id) return { payeeId: match.id };
14582
+ const payeeId = crypto.randomUUID();
14583
+ const newPayee = {
14584
+ id: payeeId,
14585
+ is_tombstone: false,
14586
+ entities_account_id: null,
14587
+ enabled: true,
14588
+ auto_fill_subcategory_id: null,
14589
+ auto_fill_memo: null,
14590
+ auto_fill_amount: 0,
14591
+ auto_fill_subcategory_enabled: true,
14592
+ auto_fill_memo_enabled: false,
14593
+ auto_fill_amount_enabled: false,
14594
+ rename_on_import_enabled: true,
14595
+ name: payeeName,
14596
+ internal_name: null
14597
+ };
14598
+ return { payeeId, newPayee };
14599
+ };
14600
+ var buildAccountCalcMap = (entities) => new Map(
14601
+ (entities.be_account_calculations ?? []).filter((c) => c.entities_account_id).map((c) => [c.entities_account_id, c])
14602
+ );
14603
+ var buildMonthlyBudgetCalcMap = (calcs) => {
14604
+ const map2 = /* @__PURE__ */ new Map();
14605
+ for (const calc of calcs) {
14606
+ const entityId = calc.entities_monthly_budget_id;
14607
+ if (!entityId) continue;
14608
+ const parts = entityId.split("/");
14609
+ const month = parts[1];
14610
+ if (parts.length >= 2 && month) map2.set(month, calc);
14611
+ }
14612
+ return map2;
14613
+ };
14614
+ var subcategoryCalcKey = (month, categoryId) => `${month}/${categoryId}`;
14615
+ var parseSubcategoryEntityId = (entityId) => {
14616
+ if (!entityId) return null;
14617
+ const parts = entityId.split("/");
14618
+ if (parts.length !== 3) return null;
14619
+ const [, month, categoryId] = parts;
14620
+ if (!month || !categoryId) return null;
14621
+ return { month, categoryId };
14622
+ };
14623
+ var buildSubcategoryCalcMap = (calcs) => {
14624
+ const map2 = /* @__PURE__ */ new Map();
14625
+ for (const calc of calcs) {
14626
+ const parsed = parseSubcategoryEntityId(calc.entities_monthly_subcategory_budget_id);
14627
+ if (parsed) map2.set(subcategoryCalcKey(parsed.month, parsed.categoryId), calc);
14628
+ }
14629
+ return map2;
14630
+ };
14631
+ var buildSubcategoryBudgetMap = (budgets) => {
14632
+ const map2 = /* @__PURE__ */ new Map();
14633
+ for (const budget of budgets) {
14634
+ if (budget.is_tombstone) continue;
14635
+ const parsed = parseSubcategoryEntityId(budget.id);
14636
+ if (parsed) map2.set(subcategoryCalcKey(parsed.month, parsed.categoryId), budget);
14637
+ }
14638
+ return map2;
14639
+ };
14640
+ var mapCategoryForMonth = (c, budgetMap, calcMap, month) => {
14641
+ const key = subcategoryCalcKey(month, c.id ?? "");
14642
+ const budget = budgetMap.get(key);
14643
+ const calc = calcMap.get(key);
14644
+ return mapCategory({
14645
+ ...c,
14646
+ budgeted: budget?.budgeted ?? c.budgeted,
14647
+ activity: (calc?.cash_outflows ?? 0) + (calc?.credit_outflows ?? 0),
14648
+ balance: calc?.balance ?? c.balance,
14649
+ goal_percentage_complete: calc?.goal_percentage_complete ?? c.goal_percentage_complete
14650
+ });
14651
+ };
14652
+ var hasId = (x) => !!x.id;
14653
+ var buildLookups = (entities) => ({
14654
+ payees: new Map(
14655
+ (entities.be_payees ?? []).filter(notTombstone).filter(hasId).map((p) => [p.id, p.name ?? ""])
14656
+ ),
14657
+ accounts: new Map(
14658
+ (entities.be_accounts ?? []).filter(notTombstone).filter(hasId).map((a) => [a.id, a.account_name ?? ""])
14659
+ ),
14660
+ categories: new Map(
14661
+ (entities.be_subcategories ?? []).filter(notTombstone).filter(hasId).map((c) => [c.id, c.name ?? ""])
14662
+ )
14663
+ });
14375
14664
  var mapUser = (u) => ({
14376
14665
  id: u.id ?? "",
14377
14666
  first_name: u.first_name ?? "",
@@ -14386,8 +14675,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14386
14675
  const df = JSON.parse(p.date_format);
14387
14676
  dateFormat = df.format ?? "";
14388
14677
  }
14389
- } catch {
14390
- dateFormat = p.date_format ?? "";
14678
+ } catch (err2) {
14679
+ log.warn("mapPlan: failed to parse date_format", { raw: p.date_format, err: err2 });
14391
14680
  }
14392
14681
  try {
14393
14682
  if (p.currency_format) {
@@ -14395,7 +14684,8 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14395
14684
  currencySymbol = cf.currency_symbol ?? "$";
14396
14685
  currencyIsoCode = cf.iso_code ?? "USD";
14397
14686
  }
14398
- } catch {
14687
+ } catch (err2) {
14688
+ log.warn("mapPlan: failed to parse currency_format", { raw: p.currency_format, err: err2 });
14399
14689
  }
14400
14690
  return {
14401
14691
  id: p.id ?? "",
@@ -14435,7 +14725,15 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14435
14725
  activity_milliunits: c.activity ?? 0,
14436
14726
  balance_milliunits: c.balance ?? 0,
14437
14727
  goal_type: c.goal_type ?? "",
14438
- goal_target: formatMilliunits(c.goal_target ?? 0),
14728
+ // The "goal target" the user configured is stored on the category itself:
14729
+ // - MF (legacy Monthly Funding): the static target lives in `monthly_funding`
14730
+ // - All modern goal types (NEED, TB, TBD, DEBT): use `goal_target_amount`
14731
+ // The calc's `goal_target` is YNAB's dynamically-computed "needed this month",
14732
+ // which for refill goals returns max(0, target - current_balance) — surprising
14733
+ // and not what users mean when they ask for the goal target.
14734
+ goal_target: formatMilliunits(
14735
+ (c.goal_type === GOAL_TYPE.MONTHLY_FUNDING ? c.monthly_funding : c.goal_target_amount) ?? 0
14736
+ ),
14439
14737
  goal_percentage_complete: c.goal_percentage_complete ?? 0
14440
14738
  });
14441
14739
  var mapPayee = (p) => ({
@@ -14443,36 +14741,36 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14443
14741
  name: p.name ?? "",
14444
14742
  transfer_account_id: p.entities_account_id ?? ""
14445
14743
  });
14446
- var mapTransaction = (t) => ({
14744
+ var mapTransaction = (t, lookups) => ({
14447
14745
  id: t.id ?? "",
14448
14746
  date: t.date ?? "",
14449
14747
  amount: formatMilliunits(t.amount ?? 0),
14450
14748
  amount_milliunits: t.amount ?? 0,
14451
14749
  memo: t.memo ?? "",
14452
- cleared: t.cleared ?? "uncleared",
14453
- approved: t.approved ?? false,
14454
- flag_color: t.flag_color ?? "",
14455
- flag_name: t.flag_name ?? "",
14750
+ cleared: t.cleared?.toLowerCase() ?? "uncleared",
14751
+ approved: t.accepted ?? false,
14752
+ flag_color: t.flag?.toLowerCase() ?? "",
14456
14753
  account_id: t.entities_account_id ?? "",
14457
- account_name: t.account_name ?? "",
14754
+ account_name: lookups?.accounts.get(t.entities_account_id ?? "") ?? "",
14458
14755
  payee_id: t.entities_payee_id ?? "",
14459
- payee_name: t.payee_name ?? "",
14756
+ payee_name: lookups?.payees.get(t.entities_payee_id ?? "") ?? "",
14460
14757
  category_id: t.entities_subcategory_id ?? "",
14461
- category_name: t.category_name ?? "",
14758
+ category_name: lookups?.categories.get(t.entities_subcategory_id ?? "") ?? "",
14462
14759
  transfer_account_id: t.transfer_account_id ?? "",
14463
- import_id: t.import_id ?? "",
14760
+ imported_payee: t.imported_payee ?? "",
14761
+ original_imported_payee: t.original_imported_payee ?? "",
14464
14762
  deleted: t.is_tombstone === true
14465
14763
  });
14466
- var mapSubtransaction = (s) => ({
14764
+ var mapSubtransaction = (s, lookups) => ({
14467
14765
  id: s.id ?? "",
14468
14766
  transaction_id: s.entities_transaction_id ?? "",
14469
14767
  amount: formatMilliunits(s.amount ?? 0),
14470
14768
  amount_milliunits: s.amount ?? 0,
14471
14769
  memo: s.memo ?? "",
14472
14770
  payee_id: s.entities_payee_id ?? "",
14473
- payee_name: s.payee_name ?? "",
14771
+ payee_name: lookups?.payees.get(s.entities_payee_id ?? "") ?? "",
14474
14772
  category_id: s.entities_subcategory_id ?? "",
14475
- category_name: s.category_name ?? "",
14773
+ category_name: lookups?.categories.get(s.entities_subcategory_id ?? "") ?? "",
14476
14774
  transfer_account_id: s.transfer_account_id ?? "",
14477
14775
  deleted: s.is_tombstone === true
14478
14776
  });
@@ -14491,87 +14789,331 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14491
14789
  budgeted_milliunits: budgeted,
14492
14790
  activity_milliunits: activity,
14493
14791
  to_be_budgeted_milliunits: toBeBudgeted,
14494
- age_of_money: calc?.age_of_money ?? 0
14792
+ age_of_money: calc?.age_of_money ?? null
14495
14793
  };
14496
14794
  };
14497
- var mapScheduledTransaction = (s) => ({
14795
+ var mapScheduledTransaction = (s, lookups) => ({
14498
14796
  id: s.id ?? "",
14499
- date_first: s.date_first ?? "",
14500
- date_next: s.date_next ?? "",
14797
+ date_first: s.date ?? "",
14798
+ date_next: s.upcoming_instances?.[0] ?? s.date ?? "",
14501
14799
  frequency: s.frequency ?? "never",
14502
14800
  amount: formatMilliunits(s.amount ?? 0),
14503
14801
  amount_milliunits: s.amount ?? 0,
14504
14802
  memo: s.memo ?? "",
14505
- flag_color: s.flag_color ?? "",
14803
+ flag_color: s.flag?.toLowerCase() ?? "",
14506
14804
  account_id: s.entities_account_id ?? "",
14507
- account_name: s.account_name ?? "",
14805
+ account_name: lookups?.accounts.get(s.entities_account_id ?? "") ?? "",
14508
14806
  payee_id: s.entities_payee_id ?? "",
14509
- payee_name: s.payee_name ?? "",
14807
+ payee_name: lookups?.payees.get(s.entities_payee_id ?? "") ?? "",
14510
14808
  category_id: s.entities_subcategory_id ?? "",
14511
- category_name: s.category_name ?? "",
14809
+ category_name: lookups?.categories.get(s.entities_subcategory_id ?? "") ?? "",
14512
14810
  deleted: s.is_tombstone === true
14513
14811
  });
14514
14812
 
14515
- // src/tools/get-current-user.ts
14516
- var getCurrentUser = defineTool({
14517
- name: "get_current_user",
14518
- displayName: "Get Current User",
14519
- description: "Get the profile of the currently authenticated YNAB user including name and email.",
14520
- summary: "Get your YNAB user profile",
14521
- icon: "user",
14522
- group: "Account",
14523
- input: external_exports.object({}),
14524
- output: external_exports.object({ user: userSchema }),
14525
- handle: async () => {
14526
- const data = await api("/user");
14527
- return { user: mapUser(data) };
14813
+ // src/tools/create-category.ts
14814
+ var createCategory = defineTool({
14815
+ name: "create_category",
14816
+ displayName: "Create Category",
14817
+ description: 'Create a new category in an existing category group. Optionally set an initial goal: "set_aside" (set aside X per cadence), "refill" (refill the balance up to X per cadence), "target_balance" (have a balance of X), or "target_by_date" (have a balance of X by a specific date). NEED-style goals (set_aside, refill) accept weekly/monthly/yearly cadence. Debt goals are not supported for new categories \u2014 they only apply to existing debt-account categories.',
14818
+ summary: "Create a new budget category",
14819
+ icon: "plus",
14820
+ group: "Categories",
14821
+ input: external_exports.object({
14822
+ group_id: external_exports.string().min(1).describe("Category group ID to create the category in"),
14823
+ name: external_exports.string().min(1).describe("Name of the new category"),
14824
+ goal: goalSpecSchema.optional().describe("Optional initial goal for the category"),
14825
+ note: external_exports.string().optional().describe("Optional note for the category")
14826
+ }),
14827
+ output: external_exports.object({
14828
+ category: categorySchema
14829
+ }),
14830
+ handle: async (params) => {
14831
+ if (params.goal?.type === "debt") {
14832
+ throw ToolError.validation("Debt goals can only be set on debt-account categories.");
14833
+ }
14834
+ const planId = getPlanId();
14835
+ const categoryId = crypto.randomUUID();
14836
+ const budget = await syncBudget(planId);
14837
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
14838
+ assertCategoryGroupDeletable(findCategoryGroup(budget.changed_entities, params.group_id));
14839
+ const childCategories = (budget.changed_entities?.be_subcategories ?? []).filter(
14840
+ (c) => c.entities_master_category_id === params.group_id
14841
+ );
14842
+ const monthKey = currentMonthKey();
14843
+ const categoryEntry = {
14844
+ id: categoryId,
14845
+ is_tombstone: false,
14846
+ entities_master_category_id: params.group_id,
14847
+ entities_account_id: null,
14848
+ internal_name: null,
14849
+ sortable_index: nextTopSortableIndex(childCategories, 5),
14850
+ name: params.name,
14851
+ type: CATEGORY_TYPE_DEFAULT,
14852
+ note: params.note ?? null,
14853
+ monthly_funding: 0,
14854
+ is_hidden: false,
14855
+ pinned_index: null,
14856
+ pinned_goal_index: null,
14857
+ ...buildGoalFields(params.goal)
14858
+ };
14859
+ const budgetEntry = {
14860
+ id: formatSubcategoryBudgetId(monthKey, categoryId),
14861
+ is_tombstone: false,
14862
+ entities_monthly_budget_id: formatMonthlyBudgetId(monthKey, planId),
14863
+ entities_subcategory_id: categoryId,
14864
+ budgeted: 0
14865
+ };
14866
+ await syncWrite(
14867
+ planId,
14868
+ {
14869
+ be_subcategories: [categoryEntry],
14870
+ be_monthly_subcategory_budgets: [budgetEntry]
14871
+ },
14872
+ serverKnowledge
14873
+ );
14874
+ return { category: mapCategory(categoryEntry) };
14528
14875
  }
14529
14876
  });
14530
14877
 
14531
- // src/tools/get-plan.ts
14532
- var getPlan = defineTool({
14533
- name: "get_plan",
14534
- displayName: "Get Plan",
14535
- description: "Get details about the currently active YNAB plan (budget), including name, currency, and date format. The plan ID is extracted from the current URL.",
14536
- summary: "Get the active plan details",
14537
- icon: "wallet",
14538
- group: "Plans",
14539
- input: external_exports.object({}),
14540
- output: external_exports.object({ plan: planSchema }),
14541
- handle: async () => {
14878
+ // src/tools/create-category-group.ts
14879
+ var createCategoryGroup = defineTool({
14880
+ name: "create_category_group",
14881
+ displayName: "Create Category Group",
14882
+ description: "Create a new category group in the active YNAB plan.",
14883
+ summary: "Create a category group",
14884
+ icon: "folder-plus",
14885
+ group: "Categories",
14886
+ input: external_exports.object({
14887
+ name: external_exports.string().min(1).describe("Name of the new category group")
14888
+ }),
14889
+ output: external_exports.object({
14890
+ group: categoryGroupSchema
14891
+ }),
14892
+ handle: async (params) => {
14542
14893
  const planId = getPlanId();
14543
- const result = await catalog("getInitialUserData", {
14544
- device_info: { id: planId, device_os: "web" }
14545
- });
14546
- return { plan: mapPlan(result.budget_version) };
14894
+ const groupId = crypto.randomUUID();
14895
+ const budget = await syncBudget(planId);
14896
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
14897
+ const groupEntry = {
14898
+ id: groupId,
14899
+ is_tombstone: false,
14900
+ internal_name: "",
14901
+ deletable: true,
14902
+ sortable_index: nextTopSortableIndex(budget.changed_entities?.be_master_categories ?? []),
14903
+ name: params.name,
14904
+ note: "",
14905
+ is_hidden: false
14906
+ };
14907
+ await syncWrite(planId, { be_master_categories: [groupEntry] }, serverKnowledge);
14908
+ return { group: mapCategoryGroup(groupEntry) };
14547
14909
  }
14548
14910
  });
14549
14911
 
14550
- // src/tools/list-accounts.ts
14551
- var listAccounts = defineTool({
14552
- name: "list_accounts",
14553
- displayName: "List Accounts",
14554
- description: "List all accounts in the active YNAB plan. Returns account name, type, balances, and on-budget status. Includes checking, savings, credit cards, and tracking accounts.",
14555
- summary: "List all budget accounts",
14556
- icon: "landmark",
14557
- group: "Accounts",
14912
+ // src/tools/create-transaction.ts
14913
+ var createTransaction = defineTool({
14914
+ name: "create_transaction",
14915
+ displayName: "Create Transaction",
14916
+ description: "Create a new transaction in the active YNAB plan. Amount is in currency units (e.g. -42.50 for a $42.50 expense, 1500 for $1500 income). Negative amounts are outflows (expenses), positive amounts are inflows (income).",
14917
+ summary: "Create a new transaction",
14918
+ icon: "plus",
14919
+ group: "Transactions",
14558
14920
  input: external_exports.object({
14559
- include_closed: external_exports.boolean().optional().describe("Include closed accounts (default false)")
14921
+ account_id: external_exports.string().min(1).describe("Account ID to create the transaction in"),
14922
+ date: external_exports.string().min(1).describe("Transaction date in YYYY-MM-DD format"),
14923
+ amount: external_exports.number().describe(
14924
+ "Amount in currency units (negative for expenses, positive for income). E.g. -42.50 for a $42.50 expense."
14925
+ ),
14926
+ payee_name: external_exports.string().optional().describe("Payee name (creates new payee if not found)"),
14927
+ payee_id: external_exports.string().optional().describe("Existing payee ID (takes precedence over payee_name)"),
14928
+ category_id: external_exports.string().optional().describe("Category ID to assign"),
14929
+ memo: external_exports.string().optional().describe("Transaction memo"),
14930
+ cleared: external_exports.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status (default uncleared)"),
14931
+ approved: external_exports.boolean().optional().describe("Whether the transaction is approved (default true)"),
14932
+ flag_color: external_exports.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color")
14560
14933
  }),
14561
14934
  output: external_exports.object({
14562
- accounts: external_exports.array(accountSchema).describe("List of accounts")
14935
+ transaction: transactionSchema
14563
14936
  }),
14564
14937
  handle: async (params) => {
14565
14938
  const planId = getPlanId();
14566
- const result = await syncBudget(planId);
14567
- const entities = result.changed_entities;
14568
- const raw = entities?.be_accounts ?? [];
14569
- const calcMap = new Map((entities?.be_account_calculations ?? []).map((c) => [c.entities_account_id, c]));
14570
- let accounts = raw.filter((a) => !a.is_tombstone).map((a) => mapAccount(a, calcMap.get(a.id)));
14571
- if (!params.include_closed) {
14572
- accounts = accounts.filter((a) => !a.closed);
14939
+ const milliunits = toMilliunits(params.amount);
14940
+ const txId = crypto.randomUUID();
14941
+ const budget = await syncBudget(planId);
14942
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
14943
+ const lookups = buildLookups(budget.changed_entities ?? {});
14944
+ const changedEntities = {};
14945
+ let payeeId = params.payee_id ?? null;
14946
+ if (!payeeId && params.payee_name) {
14947
+ const resolved = resolvePayee(budget.changed_entities?.be_payees ?? [], params.payee_name);
14948
+ payeeId = resolved.payeeId;
14949
+ if (resolved.newPayee) {
14950
+ changedEntities.be_payees = [resolved.newPayee];
14951
+ lookups.payees.set(resolved.payeeId, params.payee_name);
14952
+ }
14953
+ }
14954
+ changedEntities.be_transaction_groups = [
14955
+ {
14956
+ id: txId,
14957
+ be_transaction: {
14958
+ id: txId,
14959
+ is_tombstone: false,
14960
+ entities_account_id: params.account_id,
14961
+ entities_payee_id: payeeId,
14962
+ entities_subcategory_id: params.category_id ?? null,
14963
+ entities_scheduled_transaction_id: null,
14964
+ date: params.date,
14965
+ date_entered_from_schedule: null,
14966
+ amount: milliunits,
14967
+ // cash_amount and credit_amount are server-computed splits the account
14968
+ // type determines. Captured from a credit card account create where
14969
+ // YNAB's UI sent zeros and the server populated them on response —
14970
+ // not yet verified for cash/checking accounts but likely the same
14971
+ // pattern.
14972
+ cash_amount: 0,
14973
+ credit_amount: 0,
14974
+ credit_amount_adjusted: 0,
14975
+ subcategory_credit_amount_preceding: 0,
14976
+ memo: params.memo ?? null,
14977
+ cleared: CLEARED_MAP[params.cleared ?? "uncleared"],
14978
+ // YNAB's wire format calls this "accepted"; the public tool surface uses "approved".
14979
+ accepted: params.approved ?? true,
14980
+ check_number: null,
14981
+ flag: params.flag_color ? FLAG_MAP[params.flag_color] : null,
14982
+ transfer_account_id: null,
14983
+ transfer_transaction_id: null,
14984
+ transfer_subtransaction_id: null,
14985
+ matched_transaction_id: null,
14986
+ ynab_id: null,
14987
+ // Import-related fields are only populated by bank-feed imports, not manual entry.
14988
+ imported_payee: null,
14989
+ imported_date: null,
14990
+ original_imported_payee: null,
14991
+ provider_cleansed_payee: null,
14992
+ source: null,
14993
+ debt_transaction_type: null
14994
+ },
14995
+ be_subtransactions: null
14996
+ }
14997
+ ];
14998
+ const result = await syncWrite(planId, changedEntities, serverKnowledge);
14999
+ const saved = result.changed_entities?.be_transactions?.find((t) => t.id === txId);
15000
+ if (!saved) {
15001
+ throw ToolError.internal("Transaction was created but no data was returned");
14573
15002
  }
14574
- return { accounts };
15003
+ return { transaction: mapTransaction(saved, lookups) };
15004
+ }
15005
+ });
15006
+
15007
+ // src/tools/delete-category.ts
15008
+ var deleteCategory = defineTool({
15009
+ name: "delete_category",
15010
+ displayName: "Delete Category",
15011
+ description: "Delete a category from the active YNAB plan. This is a soft delete (tombstone). Existing transactions assigned to this category remain in place but the category will no longer appear in budget views.",
15012
+ summary: "Delete a category",
15013
+ icon: "trash-2",
15014
+ group: "Categories",
15015
+ input: external_exports.object({
15016
+ category_id: external_exports.string().min(1).describe("Category ID to delete")
15017
+ }),
15018
+ output: external_exports.object({
15019
+ success: external_exports.boolean()
15020
+ }),
15021
+ handle: async (params) => {
15022
+ const planId = getPlanId();
15023
+ const budget = await syncBudget(planId);
15024
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
15025
+ const existing2 = findCategory(budget.changed_entities, params.category_id);
15026
+ assertCategoryDeletable(existing2);
15027
+ await syncWrite(
15028
+ planId,
15029
+ { be_subcategories: [{ ...existing2, is_tombstone: true }] },
15030
+ serverKnowledge
15031
+ );
15032
+ return { success: true };
15033
+ }
15034
+ });
15035
+
15036
+ // src/tools/delete-category-group.ts
15037
+ var deleteCategoryGroup = defineTool({
15038
+ name: "delete_category_group",
15039
+ displayName: "Delete Category Group",
15040
+ description: "Delete a category group and all of its child categories from the active YNAB plan. This is a soft delete (tombstone). Internal/non-deletable groups (Credit Card Payments, Hidden Categories, Internal Master Category) cannot be deleted.",
15041
+ summary: "Delete a category group and its children",
15042
+ icon: "folder-x",
15043
+ group: "Categories",
15044
+ input: external_exports.object({
15045
+ group_id: external_exports.string().min(1).describe("Category group ID to delete")
15046
+ }),
15047
+ output: external_exports.object({
15048
+ success: external_exports.boolean(),
15049
+ deleted_category_count: external_exports.number().describe("Number of child categories that were also tombstoned")
15050
+ }),
15051
+ handle: async (params) => {
15052
+ const planId = getPlanId();
15053
+ const budget = await syncBudget(planId);
15054
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
15055
+ const group = findCategoryGroup(budget.changed_entities, params.group_id);
15056
+ assertCategoryGroupDeletable(group);
15057
+ const childCategories = (budget.changed_entities?.be_subcategories ?? []).filter(
15058
+ (c) => c.entities_master_category_id === params.group_id && notTombstone(c)
15059
+ );
15060
+ await syncWrite(
15061
+ planId,
15062
+ {
15063
+ be_master_categories: [{ ...group, is_tombstone: true }],
15064
+ be_subcategories: childCategories.map((c) => ({ ...c, is_tombstone: true }))
15065
+ },
15066
+ serverKnowledge
15067
+ );
15068
+ return { success: true, deleted_category_count: childCategories.length };
15069
+ }
15070
+ });
15071
+
15072
+ // src/tools/delete-transaction.ts
15073
+ var deleteTransaction = defineTool({
15074
+ name: "delete_transaction",
15075
+ displayName: "Delete Transaction",
15076
+ description: "Delete a transaction from the active YNAB plan. This marks the transaction as deleted (soft delete). Transfer transactions cannot be deleted through this tool \u2014 delete them directly in YNAB.",
15077
+ summary: "Delete a transaction",
15078
+ icon: "trash-2",
15079
+ group: "Transactions",
15080
+ input: external_exports.object({
15081
+ transaction_id: external_exports.string().min(1).describe("Transaction ID to delete"),
15082
+ account_id: external_exports.string().min(1).describe("Account ID the transaction belongs to")
15083
+ }),
15084
+ output: external_exports.object({
15085
+ success: external_exports.boolean().describe("Whether the operation succeeded")
15086
+ }),
15087
+ handle: async (params) => {
15088
+ const planId = getPlanId();
15089
+ const budget = await syncBudget(planId);
15090
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
15091
+ const existing2 = budget.changed_entities?.be_transactions?.find(
15092
+ (t) => t.id === params.transaction_id && !t.is_tombstone
15093
+ );
15094
+ if (!existing2) {
15095
+ throw ToolError.notFound(`Transaction not found: ${params.transaction_id}`);
15096
+ }
15097
+ if (existing2.transfer_account_id) {
15098
+ throw ToolError.validation("Cannot delete transfer transactions \u2014 delete them in YNAB directly.");
15099
+ }
15100
+ await syncWrite(
15101
+ planId,
15102
+ {
15103
+ be_transaction_groups: [
15104
+ {
15105
+ id: params.transaction_id,
15106
+ be_transaction: {
15107
+ ...existing2,
15108
+ is_tombstone: true
15109
+ },
15110
+ be_subtransactions: null
15111
+ }
15112
+ ]
15113
+ },
15114
+ serverKnowledge
15115
+ );
15116
+ return { success: true };
14575
15117
  }
14576
15118
  });
14577
15119
 
@@ -14594,7 +15136,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14594
15136
  const result = await syncBudget(planId);
14595
15137
  const entities = result.changed_entities;
14596
15138
  const raw = entities?.be_accounts ?? [];
14597
- const calcMap = new Map((entities?.be_account_calculations ?? []).map((c) => [c.entities_account_id, c]));
15139
+ const calcMap = buildAccountCalcMap(entities ?? {});
14598
15140
  const account = raw.find((a) => a.id === params.account_id && !a.is_tombstone);
14599
15141
  if (!account) {
14600
15142
  throw ToolError.notFound(`Account not found: ${params.account_id}`);
@@ -14603,103 +15145,205 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14603
15145
  }
14604
15146
  });
14605
15147
 
14606
- // src/tools/list-categories.ts
14607
- var listCategories = defineTool({
14608
- name: "list_categories",
14609
- displayName: "List Categories",
14610
- description: "List all category groups and categories in the active YNAB plan. Returns budgeted amounts, activity, and available balances for the current month. Excludes hidden and deleted categories by default.",
14611
- summary: "List budget categories with balances",
14612
- icon: "tags",
14613
- group: "Categories",
15148
+ // src/tools/get-current-user.ts
15149
+ var getCurrentUser = defineTool({
15150
+ name: "get_current_user",
15151
+ displayName: "Get Current User",
15152
+ description: "Get the profile of the currently authenticated YNAB user including name and email.",
15153
+ summary: "Get your YNAB user profile",
15154
+ icon: "user",
15155
+ group: "Account",
15156
+ input: external_exports.object({}),
15157
+ output: external_exports.object({ user: userSchema }),
15158
+ handle: async () => {
15159
+ const data = await api("/user");
15160
+ return { user: mapUser(data) };
15161
+ }
15162
+ });
15163
+
15164
+ // src/tools/get-month.ts
15165
+ var getMonth = defineTool({
15166
+ name: "get_month",
15167
+ displayName: "Get Month",
15168
+ description: "Get budget summary and category details for a specific month. Returns the month overview (income, budgeted, activity, Ready to Assign) plus per-category breakdowns. Month format is YYYY-MM-DD using the first of the month (e.g. 2026-03-01).",
15169
+ summary: "Get budget details for a month",
15170
+ icon: "calendar",
15171
+ group: "Months",
14614
15172
  input: external_exports.object({
15173
+ month: external_exports.string().min(1).describe("Month in YYYY-MM-DD format (first of month, e.g. 2026-03-01)"),
14615
15174
  include_hidden: external_exports.boolean().optional().describe("Include hidden categories (default false)")
14616
15175
  }),
14617
15176
  output: external_exports.object({
14618
- groups: external_exports.array(categoryGroupSchema).describe("Category groups"),
14619
- categories: external_exports.array(categorySchema).describe("Categories with budget data")
15177
+ month: monthSchema,
15178
+ categories: external_exports.array(categorySchema).describe("Category budgets for this month")
14620
15179
  }),
14621
15180
  handle: async (params) => {
14622
15181
  const planId = getPlanId();
14623
15182
  const result = await syncBudget(planId);
14624
15183
  const entities = result.changed_entities;
14625
- const rawGroups = (entities?.be_master_categories ?? []).filter((g) => !g.is_tombstone);
14626
- const rawCategories = (entities?.be_subcategories ?? []).filter((c) => !c.is_tombstone);
14627
- const calcs = entities?.be_monthly_subcategory_budget_calculations ?? [];
14628
- const calcMap = /* @__PURE__ */ new Map();
14629
- for (const calc of calcs) {
14630
- const entityId = calc.entities_monthly_subcategory_budget_id;
14631
- if (entityId) {
14632
- const parts = entityId.split("/");
14633
- const categoryId = parts.length >= 3 ? parts.slice(2).join("/") : entityId;
14634
- calcMap.set(categoryId, calc);
14635
- }
15184
+ const rawMonths = entities?.be_monthly_budgets ?? [];
15185
+ const monthData = rawMonths.find((m) => m.month === params.month && !m.is_tombstone);
15186
+ if (!monthData) {
15187
+ throw ToolError.notFound(`Month not found: ${params.month}`);
14636
15188
  }
14637
- let groups = rawGroups.map(mapCategoryGroup);
14638
- let categories = rawCategories.map((c) => {
14639
- const calc = calcMap.get(c.id ?? "");
14640
- return mapCategory({
14641
- ...c,
14642
- budgeted: calc?.budgeted ?? c.budgeted,
14643
- activity: calc?.activity ?? c.activity,
14644
- balance: calc?.balance ?? c.balance,
14645
- goal_type: calc?.goal_type ?? c.goal_type,
14646
- goal_target: calc?.goal_target ?? c.goal_target,
14647
- goal_percentage_complete: calc?.goal_percentage_complete ?? c.goal_percentage_complete
14648
- });
15189
+ const monthKey = toMonthKey(params.month);
15190
+ const monthCalcMap = buildMonthlyBudgetCalcMap(entities?.be_monthly_budget_calculations ?? []);
15191
+ const monthCalc = monthCalcMap.get(monthKey);
15192
+ const rawCategories = (entities?.be_subcategories ?? []).filter(
15193
+ (c) => notTombstone(c) && (params.include_hidden || c.is_hidden !== true)
15194
+ );
15195
+ const budgetMap = buildSubcategoryBudgetMap(entities?.be_monthly_subcategory_budgets ?? []);
15196
+ const calcMap = buildSubcategoryCalcMap(entities?.be_monthly_subcategory_budget_calculations ?? []);
15197
+ const categories = rawCategories.map((c) => mapCategoryForMonth(c, budgetMap, calcMap, monthKey));
15198
+ return {
15199
+ month: mapMonth(monthData, monthCalc),
15200
+ categories
15201
+ };
15202
+ }
15203
+ });
15204
+
15205
+ // src/tools/get-plan.ts
15206
+ var getPlan = defineTool({
15207
+ name: "get_plan",
15208
+ displayName: "Get Plan",
15209
+ description: "Get details about the currently active YNAB plan (budget), including name, currency, and date format. The plan ID is extracted from the current URL.",
15210
+ summary: "Get the active plan details",
15211
+ icon: "wallet",
15212
+ group: "Plans",
15213
+ input: external_exports.object({}),
15214
+ output: external_exports.object({ plan: planSchema }),
15215
+ handle: async () => {
15216
+ assertAuthenticated();
15217
+ const result = await catalog("getInitialUserData", {
15218
+ device_info: { id: getDeviceId(), device_os: "web" }
14649
15219
  });
14650
- if (!params.include_hidden) {
14651
- groups = groups.filter((g) => !g.hidden);
14652
- categories = categories.filter((c) => !c.hidden);
15220
+ const budgetVersion = result.budget_version;
15221
+ if (!budgetVersion) {
15222
+ throw ToolError.notFound("No active plan found");
14653
15223
  }
14654
- return { groups, categories };
15224
+ return { plan: mapPlan(budgetVersion) };
14655
15225
  }
14656
15226
  });
14657
15227
 
14658
- // src/tools/update-category-budget.ts
14659
- var updateCategoryBudget = defineTool({
14660
- name: "update_category_budget",
14661
- displayName: "Update Category Budget",
14662
- description: "Set the budgeted amount for a category in a specific month. Amount is in currency units (e.g. 500 to budget $500). The month should be in YYYY-MM format (e.g. 2026-03 for March 2026).",
14663
- summary: "Set budgeted amount for a category",
14664
- icon: "pencil",
14665
- group: "Categories",
15228
+ // src/tools/get-transaction.ts
15229
+ var getTransaction = defineTool({
15230
+ name: "get_transaction",
15231
+ displayName: "Get Transaction",
15232
+ description: "Get details for a specific transaction by its ID. Returns full transaction data including any split subtransactions.",
15233
+ summary: "Get transaction details by ID",
15234
+ icon: "receipt",
15235
+ group: "Transactions",
14666
15236
  input: external_exports.object({
14667
- category_id: external_exports.string().min(1).describe("Category ID to budget"),
14668
- month: external_exports.string().min(1).describe("Month in YYYY-MM format (e.g. 2026-03)"),
14669
- budgeted: external_exports.number().describe("Amount to budget in currency units (e.g. 500 for $500)")
15237
+ transaction_id: external_exports.string().min(1).describe("Transaction ID to retrieve")
14670
15238
  }),
14671
15239
  output: external_exports.object({
14672
- category: categorySchema
15240
+ transaction: transactionSchema,
15241
+ subtransactions: external_exports.array(subtransactionSchema).describe("Split subtransactions (empty if not a split)")
14673
15242
  }),
14674
15243
  handle: async (params) => {
14675
15244
  const planId = getPlanId();
14676
- const milliunits = Math.round(params.budgeted * 1e3);
14677
- const monthKey = params.month.substring(0, 7);
14678
- const budgetId = `mcb/${monthKey}/${params.category_id}`;
14679
- const monthlyBudgetId = `mb/${monthKey}/${planId}`;
14680
- const result = await syncWrite(planId, {
14681
- be_monthly_subcategory_budgets: [
14682
- {
14683
- id: budgetId,
14684
- entities_monthly_budget_id: monthlyBudgetId,
14685
- entities_subcategory_id: params.category_id,
14686
- budgeted: milliunits,
14687
- overspending_handling: "AffectsBuffer",
14688
- is_tombstone: false
14689
- }
14690
- ]
14691
- });
14692
- const budgets = result.changed_entities?.be_monthly_subcategory_budgets;
14693
- const updatedBudget = budgets?.[0]?.budgeted ?? milliunits;
15245
+ const result = await syncBudget(planId);
15246
+ const entities = result.changed_entities;
15247
+ const raw = entities?.be_transactions ?? [];
15248
+ const tx = raw.find((t) => t.id === params.transaction_id && !t.is_tombstone);
15249
+ if (!tx) {
15250
+ throw ToolError.notFound(`Transaction not found: ${params.transaction_id}`);
15251
+ }
15252
+ const lookups = buildLookups(entities ?? {});
15253
+ const allSubs = entities?.be_subtransactions ?? [];
15254
+ const subtransactions = allSubs.filter((s) => s.entities_transaction_id === params.transaction_id && !s.is_tombstone).map((s) => mapSubtransaction(s, lookups));
14694
15255
  return {
14695
- category: mapCategory({
14696
- id: params.category_id,
14697
- budgeted: updatedBudget
14698
- })
15256
+ transaction: mapTransaction(tx, lookups),
15257
+ subtransactions
14699
15258
  };
14700
15259
  }
14701
15260
  });
14702
15261
 
15262
+ // src/tools/list-accounts.ts
15263
+ var listAccounts = defineTool({
15264
+ name: "list_accounts",
15265
+ displayName: "List Accounts",
15266
+ description: "List all accounts in the active YNAB plan. Returns account name, type, balances, and on-budget status. Includes checking, savings, credit cards, and tracking accounts.",
15267
+ summary: "List all budget accounts",
15268
+ icon: "landmark",
15269
+ group: "Accounts",
15270
+ input: external_exports.object({
15271
+ include_closed: external_exports.boolean().optional().describe("Include closed accounts (default false)")
15272
+ }),
15273
+ output: external_exports.object({
15274
+ accounts: external_exports.array(accountSchema).describe("List of accounts")
15275
+ }),
15276
+ handle: async (params) => {
15277
+ const planId = getPlanId();
15278
+ const result = await syncBudget(planId);
15279
+ const entities = result.changed_entities;
15280
+ const raw = entities?.be_accounts ?? [];
15281
+ const calcMap = buildAccountCalcMap(entities ?? {});
15282
+ let accounts = raw.filter(notTombstone).map((a) => mapAccount(a, calcMap.get(a.id)));
15283
+ if (!params.include_closed) {
15284
+ accounts = accounts.filter((a) => !a.closed);
15285
+ }
15286
+ return { accounts };
15287
+ }
15288
+ });
15289
+
15290
+ // src/tools/list-categories.ts
15291
+ var listCategories = defineTool({
15292
+ name: "list_categories",
15293
+ displayName: "List Categories",
15294
+ description: "List category groups and categories in the active YNAB plan. Returns budgeted amounts, activity, and available balances for the current month. Hidden and deleted categories are excluded by default \u2014 pass include_hidden=true to also see hidden categories (useful for editing budgets on hidden categories).",
15295
+ summary: "List budget categories with balances",
15296
+ icon: "tags",
15297
+ group: "Categories",
15298
+ input: external_exports.object({
15299
+ include_hidden: external_exports.boolean().optional().describe("Include hidden categories (default false)")
15300
+ }),
15301
+ output: external_exports.object({
15302
+ groups: external_exports.array(categoryGroupSchema).describe("Category groups"),
15303
+ categories: external_exports.array(categorySchema).describe("Categories with budget data")
15304
+ }),
15305
+ handle: async (params) => {
15306
+ const planId = getPlanId();
15307
+ const result = await syncBudget(planId);
15308
+ const entities = result.changed_entities;
15309
+ const rawGroups = (entities?.be_master_categories ?? []).filter(notTombstone);
15310
+ const rawCategories = (entities?.be_subcategories ?? []).filter(notTombstone);
15311
+ const budgetMap = buildSubcategoryBudgetMap(entities?.be_monthly_subcategory_budgets ?? []);
15312
+ const calcMap = buildSubcategoryCalcMap(entities?.be_monthly_subcategory_budget_calculations ?? []);
15313
+ const currentMonth = currentMonthKey();
15314
+ let groups = rawGroups.map(mapCategoryGroup);
15315
+ let categories = rawCategories.map((c) => mapCategoryForMonth(c, budgetMap, calcMap, currentMonth));
15316
+ if (!params.include_hidden) {
15317
+ groups = groups.filter((g) => !g.hidden);
15318
+ categories = categories.filter((c) => !c.hidden);
15319
+ }
15320
+ return { groups, categories };
15321
+ }
15322
+ });
15323
+
15324
+ // src/tools/list-months.ts
15325
+ var listMonths = defineTool({
15326
+ name: "list_months",
15327
+ displayName: "List Months",
15328
+ description: "List all budget months in the active YNAB plan. Returns income, budgeted, activity, and Ready to Assign amounts for each month. Sorted from most recent to oldest.",
15329
+ summary: "List budget months with summaries",
15330
+ icon: "calendar",
15331
+ group: "Months",
15332
+ input: external_exports.object({}),
15333
+ output: external_exports.object({
15334
+ months: external_exports.array(monthSchema).describe("List of budget months")
15335
+ }),
15336
+ handle: async () => {
15337
+ const planId = getPlanId();
15338
+ const result = await syncBudget(planId);
15339
+ const entities = result.changed_entities;
15340
+ const rawMonths = entities?.be_monthly_budgets ?? [];
15341
+ const calcMap = buildMonthlyBudgetCalcMap(entities?.be_monthly_budget_calculations ?? []);
15342
+ const months = rawMonths.filter(notTombstone).map((m) => mapMonth(m, calcMap.get((m.month ?? "").substring(0, 7)))).sort((a, b) => b.month.localeCompare(a.month));
15343
+ return { months };
15344
+ }
15345
+ });
15346
+
14703
15347
  // src/tools/list-payees.ts
14704
15348
  var listPayees = defineTool({
14705
15349
  name: "list_payees",
@@ -14716,11 +15360,34 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14716
15360
  const planId = getPlanId();
14717
15361
  const result = await syncBudget(planId);
14718
15362
  const raw = result.changed_entities?.be_payees ?? [];
14719
- const payees = raw.filter((p) => !p.is_tombstone).map(mapPayee);
15363
+ const payees = raw.filter(notTombstone).map(mapPayee);
14720
15364
  return { payees };
14721
15365
  }
14722
15366
  });
14723
15367
 
15368
+ // src/tools/list-scheduled-transactions.ts
15369
+ var listScheduledTransactions = defineTool({
15370
+ name: "list_scheduled_transactions",
15371
+ displayName: "List Scheduled Transactions",
15372
+ description: "List all scheduled (recurring) transactions in the active YNAB plan. Returns frequency, next occurrence date, amount, payee, and category for each.",
15373
+ summary: "List scheduled/recurring transactions",
15374
+ icon: "clock",
15375
+ group: "Transactions",
15376
+ input: external_exports.object({}),
15377
+ output: external_exports.object({
15378
+ scheduled_transactions: external_exports.array(scheduledTransactionSchema).describe("List of scheduled transactions")
15379
+ }),
15380
+ handle: async () => {
15381
+ const planId = getPlanId();
15382
+ const result = await syncBudget(planId);
15383
+ const entities = result.changed_entities;
15384
+ const raw = entities?.be_scheduled_transactions ?? [];
15385
+ const lookups = buildLookups(entities ?? {});
15386
+ const scheduledTransactions = raw.filter(notTombstone).map((s) => mapScheduledTransaction(s, lookups)).sort((a, b) => a.date_next.localeCompare(b.date_next));
15387
+ return { scheduled_transactions: scheduledTransactions };
15388
+ }
15389
+ });
15390
+
14724
15391
  // src/tools/list-transactions.ts
14725
15392
  var listTransactions = defineTool({
14726
15393
  name: "list_transactions",
@@ -14731,7 +15398,13 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14731
15398
  group: "Transactions",
14732
15399
  input: external_exports.object({
14733
15400
  account_id: external_exports.string().optional().describe("Filter by account ID. Omit to list all transactions."),
14734
- since_date: external_exports.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD). Omit for all transactions.")
15401
+ since_date: external_exports.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD). Omit for all transactions."),
15402
+ until_date: external_exports.string().optional().describe(
15403
+ "Only return transactions on or before this date (YYYY-MM-DD). Combine with since_date for a date range."
15404
+ ),
15405
+ payee_search: external_exports.string().optional().describe(
15406
+ "Case-insensitive substring match against payee_name, imported_payee, and original_imported_payee. Useful for finding all transactions for a merchant without knowing the exact payee ID."
15407
+ )
14735
15408
  }),
14736
15409
  output: external_exports.object({
14737
15410
  transactions: external_exports.array(transactionSchema).describe("List of transactions")
@@ -14739,112 +15412,292 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14739
15412
  handle: async (params) => {
14740
15413
  const planId = getPlanId();
14741
15414
  const result = await syncBudget(planId);
14742
- const raw = result.changed_entities?.be_transactions ?? [];
14743
- let transactions = raw.filter((t) => !t.is_tombstone).map(mapTransaction);
15415
+ const entities = result.changed_entities;
15416
+ const raw = entities?.be_transactions ?? [];
15417
+ const lookups = buildLookups(entities ?? {});
15418
+ let filtered = raw.filter(notTombstone);
14744
15419
  if (params.account_id) {
14745
- transactions = transactions.filter((t) => t.account_id === params.account_id);
15420
+ filtered = filtered.filter((t) => t.entities_account_id === params.account_id);
14746
15421
  }
14747
15422
  if (params.since_date) {
14748
15423
  const sinceDate = params.since_date;
14749
- transactions = transactions.filter((t) => t.date >= sinceDate);
15424
+ filtered = filtered.filter((t) => (t.date ?? "") >= sinceDate);
15425
+ }
15426
+ if (params.until_date) {
15427
+ const untilDate = params.until_date;
15428
+ filtered = filtered.filter((t) => (t.date ?? "") <= untilDate);
15429
+ }
15430
+ if (params.payee_search) {
15431
+ const needle = params.payee_search.toLowerCase();
15432
+ const matchingPayeeIds = /* @__PURE__ */ new Set();
15433
+ for (const [id, name] of lookups.payees) {
15434
+ if (name.toLowerCase().includes(needle)) matchingPayeeIds.add(id);
15435
+ }
15436
+ filtered = filtered.filter((t) => {
15437
+ if (t.entities_payee_id && matchingPayeeIds.has(t.entities_payee_id)) return true;
15438
+ if (t.imported_payee?.toLowerCase().includes(needle)) return true;
15439
+ if (t.original_imported_payee?.toLowerCase().includes(needle)) return true;
15440
+ return false;
15441
+ });
14750
15442
  }
14751
- transactions.sort((a, b) => b.date.localeCompare(a.date));
15443
+ const transactions = filtered.map((t) => mapTransaction(t, lookups)).sort((a, b) => b.date.localeCompare(a.date));
14752
15444
  return { transactions };
14753
15445
  }
14754
15446
  });
14755
15447
 
14756
- // src/tools/get-transaction.ts
14757
- var getTransaction = defineTool({
14758
- name: "get_transaction",
14759
- displayName: "Get Transaction",
14760
- description: "Get details for a specific transaction by its ID. Returns full transaction data including any split subtransactions.",
14761
- summary: "Get transaction details by ID",
14762
- icon: "receipt",
14763
- group: "Transactions",
15448
+ // src/tools/move-category-budget.ts
15449
+ var moveCategoryBudget = defineTool({
15450
+ name: "move_category_budget",
15451
+ displayName: "Move Category Budget",
15452
+ description: "Move budgeted money between categories or to/from Ready to Assign for a specific month. Omit from_category_id to move money out of Ready to Assign; omit to_category_id to move money back to Ready to Assign. Both null is invalid.",
15453
+ summary: "Move money between budget categories",
15454
+ icon: "arrow-left-right",
15455
+ group: "Categories",
14764
15456
  input: external_exports.object({
14765
- transaction_id: external_exports.string().min(1).describe("Transaction ID to retrieve")
15457
+ month: external_exports.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, "Month must be YYYY-MM or YYYY-MM-DD").describe("Month in YYYY-MM format (e.g. 2026-03)"),
15458
+ amount: external_exports.number().positive().describe("Amount to move in currency units (e.g. 50 for $50)"),
15459
+ from_category_id: external_exports.string().optional().describe("Source category ID. Omit to move from Ready to Assign."),
15460
+ to_category_id: external_exports.string().optional().describe("Destination category ID. Omit to move to Ready to Assign.")
15461
+ }).refine((p) => p.from_category_id || p.to_category_id, {
15462
+ message: "At least one of from_category_id or to_category_id must be provided"
15463
+ }).refine((p) => !p.from_category_id || !p.to_category_id || p.from_category_id !== p.to_category_id, {
15464
+ message: "from_category_id and to_category_id must differ"
14766
15465
  }),
14767
15466
  output: external_exports.object({
14768
- transaction: transactionSchema,
14769
- subtransactions: external_exports.array(subtransactionSchema).describe("Split subtransactions (empty if not a split)")
15467
+ categories: external_exports.array(categorySchema).describe("Updated categories (1 if RTA is involved, 2 for category-to-category)")
14770
15468
  }),
14771
15469
  handle: async (params) => {
14772
15470
  const planId = getPlanId();
14773
- const result = await syncBudget(planId);
14774
- const entities = result.changed_entities;
14775
- const raw = entities?.be_transactions ?? [];
14776
- const tx = raw.find((t) => t.id === params.transaction_id && !t.is_tombstone);
14777
- if (!tx) {
14778
- throw ToolError.notFound(`Transaction not found: ${params.transaction_id}`);
15471
+ const userId = getUserId();
15472
+ const milliunits = toMilliunits(params.amount);
15473
+ const monthKey = toMonthKey(params.month);
15474
+ const monthlyBudgetId = formatMonthlyBudgetId(monthKey, planId);
15475
+ const budget = await syncBudget(planId);
15476
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
15477
+ const existingBudgets = budget.changed_entities?.be_monthly_subcategory_budgets ?? [];
15478
+ const fromCategoryId = params.from_category_id;
15479
+ const toCategoryId = params.to_category_id;
15480
+ const fromCategory = fromCategoryId ? findCategory(budget.changed_entities, fromCategoryId) : null;
15481
+ const toCategory = toCategoryId ? findCategory(budget.changed_entities, toCategoryId) : null;
15482
+ const fromBudgetId = fromCategoryId ? formatSubcategoryBudgetId(monthKey, fromCategoryId) : null;
15483
+ const toBudgetId = toCategoryId ? formatSubcategoryBudgetId(monthKey, toCategoryId) : null;
15484
+ const buildEntry = (categoryId, budgetId, signedDelta) => {
15485
+ const current = existingBudgets.find((b) => b.id === budgetId && notTombstone(b))?.budgeted ?? 0;
15486
+ return {
15487
+ id: budgetId,
15488
+ is_tombstone: false,
15489
+ entities_monthly_budget_id: monthlyBudgetId,
15490
+ entities_subcategory_id: categoryId,
15491
+ budgeted: current + signedDelta
15492
+ };
15493
+ };
15494
+ const budgetEntries = [];
15495
+ if (fromCategoryId && fromBudgetId) budgetEntries.push(buildEntry(fromCategoryId, fromBudgetId, -milliunits));
15496
+ if (toCategoryId && toBudgetId) budgetEntries.push(buildEntry(toCategoryId, toBudgetId, milliunits));
15497
+ const source = fromCategoryId && toCategoryId ? MONEY_MOVEMENT_SOURCE.MOVEMENT : MONEY_MOVEMENT_SOURCE.ASSIGN;
15498
+ const result = await syncWrite(
15499
+ planId,
15500
+ {
15501
+ be_monthly_subcategory_budgets: budgetEntries,
15502
+ be_money_movements: [
15503
+ {
15504
+ id: crypto.randomUUID(),
15505
+ is_tombstone: false,
15506
+ from_entities_monthly_subcategory_budget_id: fromBudgetId,
15507
+ to_entities_monthly_subcategory_budget_id: toBudgetId,
15508
+ entities_money_movement_group_id: null,
15509
+ amount: milliunits,
15510
+ performed_by_user_id: userId,
15511
+ note: null,
15512
+ source,
15513
+ move_started_at: (/* @__PURE__ */ new Date()).toISOString(),
15514
+ move_accepted_at: null
15515
+ }
15516
+ ]
15517
+ },
15518
+ serverKnowledge
15519
+ );
15520
+ const calcMap = buildSubcategoryCalcMap(result.changed_entities?.be_monthly_subcategory_budget_calculations ?? []);
15521
+ const budgetMap = buildSubcategoryBudgetMap(result.changed_entities?.be_monthly_subcategory_budgets ?? []);
15522
+ for (const e of budgetEntries) {
15523
+ const key = `${monthKey}/${e.entities_subcategory_id}`;
15524
+ if (!budgetMap.has(key)) budgetMap.set(key, e);
14779
15525
  }
14780
- const allSubs = entities?.be_subtransactions ?? [];
14781
- const subtransactions = allSubs.filter((s) => s.entities_transaction_id === params.transaction_id && !s.is_tombstone).map(mapSubtransaction);
14782
- return {
14783
- transaction: mapTransaction(tx),
14784
- subtransactions
15526
+ const categories = [];
15527
+ if (fromCategory) categories.push(mapCategoryForMonth(fromCategory, budgetMap, calcMap, monthKey));
15528
+ if (toCategory) categories.push(mapCategoryForMonth(toCategory, budgetMap, calcMap, monthKey));
15529
+ return { categories };
15530
+ }
15531
+ });
15532
+
15533
+ // src/tools/snooze-category-goal.ts
15534
+ var snoozeCategoryGoal = defineTool({
15535
+ name: "snooze_category_goal",
15536
+ displayName: "Snooze Category Goal",
15537
+ description: "Snooze a category goal for a specific month so it does not appear as needing funding for that month. Pass snooze=false to un-snooze.",
15538
+ summary: "Snooze a category goal for a month",
15539
+ icon: "bell-off",
15540
+ group: "Categories",
15541
+ input: external_exports.object({
15542
+ category_id: external_exports.string().min(1).describe("Category ID whose goal to snooze"),
15543
+ month: external_exports.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, "Month must be YYYY-MM or YYYY-MM-DD").describe("Month in YYYY-MM format (e.g. 2026-04)"),
15544
+ snooze: external_exports.boolean().optional().describe("true to snooze (default), false to un-snooze")
15545
+ }),
15546
+ output: external_exports.object({
15547
+ success: external_exports.boolean(),
15548
+ snoozed_at: external_exports.string().nullable().describe("ISO timestamp the goal was snoozed at, or null if un-snoozed")
15549
+ }),
15550
+ handle: async (params) => {
15551
+ const planId = getPlanId();
15552
+ const monthKey = toMonthKey(params.month);
15553
+ const budgetId = formatSubcategoryBudgetId(monthKey, params.category_id);
15554
+ const monthlyBudgetId = formatMonthlyBudgetId(monthKey, planId);
15555
+ const shouldSnooze = params.snooze ?? true;
15556
+ const budget = await syncBudget(planId);
15557
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
15558
+ const category = findCategory(budget.changed_entities, params.category_id);
15559
+ if (!category.goal_type) {
15560
+ throw ToolError.validation(`Category "${category.name}" has no goal to snooze.`);
15561
+ }
15562
+ const existing2 = (budget.changed_entities?.be_monthly_subcategory_budgets ?? []).find(
15563
+ (b) => b.id === budgetId && notTombstone(b)
15564
+ );
15565
+ const snoozedAt = shouldSnooze ? (/* @__PURE__ */ new Date()).toISOString() : null;
15566
+ const budgetEntry = {
15567
+ ...existing2 ?? {},
15568
+ id: budgetId,
15569
+ is_tombstone: false,
15570
+ entities_monthly_budget_id: monthlyBudgetId,
15571
+ entities_subcategory_id: params.category_id,
15572
+ budgeted: existing2?.budgeted ?? 0,
15573
+ goal_snoozed_at: snoozedAt
14785
15574
  };
15575
+ await syncWrite(planId, { be_monthly_subcategory_budgets: [budgetEntry] }, serverKnowledge);
15576
+ return { success: true, snoozed_at: snoozedAt };
14786
15577
  }
14787
15578
  });
14788
15579
 
14789
- // src/tools/create-transaction.ts
14790
- var createTransaction = defineTool({
14791
- name: "create_transaction",
14792
- displayName: "Create Transaction",
14793
- description: "Create a new transaction in the active YNAB plan. Amount is in currency units (e.g. -42.50 for a $42.50 expense, 1500 for $1500 income). Negative amounts are outflows (expenses), positive amounts are inflows (income).",
14794
- summary: "Create a new transaction",
14795
- icon: "plus",
14796
- group: "Transactions",
15580
+ // src/tools/update-category.ts
15581
+ var updateCategory = defineTool({
15582
+ name: "update_category",
15583
+ displayName: "Update Category",
15584
+ description: 'Rename a category, change its group, set/clear its goal, or hide/unhide it. Only specified fields change; omitted fields remain unchanged. Goal types: "set_aside" / "refill" (recurring NEED with optional cadence), "target_balance" (have $X), "target_by_date" (have $X by date), "debt" (recurring debt payment), or "none" to clear an existing goal.',
15585
+ summary: "Update a category",
15586
+ icon: "pencil",
15587
+ group: "Categories",
14797
15588
  input: external_exports.object({
14798
- account_id: external_exports.string().min(1).describe("Account ID to create the transaction in"),
14799
- date: external_exports.string().min(1).describe("Transaction date in YYYY-MM-DD format"),
14800
- amount: external_exports.number().describe(
14801
- "Amount in currency units (negative for expenses, positive for income). E.g. -42.50 for a $42.50 expense."
14802
- ),
14803
- payee_name: external_exports.string().optional().describe("Payee name (creates new payee if not found)"),
14804
- payee_id: external_exports.string().optional().describe("Existing payee ID (takes precedence over payee_name)"),
14805
- category_id: external_exports.string().optional().describe("Category ID to assign"),
14806
- memo: external_exports.string().optional().describe("Transaction memo"),
14807
- cleared: external_exports.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status (default uncleared)"),
14808
- approved: external_exports.boolean().optional().describe("Whether the transaction is approved (default true)"),
14809
- flag_color: external_exports.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("Flag color")
15589
+ category_id: external_exports.string().min(1).describe("Category ID to update"),
15590
+ name: external_exports.string().min(1).optional().describe("New name"),
15591
+ group_id: external_exports.string().min(1).optional().describe("New parent category group ID (to move the category)"),
15592
+ goal: goalSpecSchema.optional().describe('New goal definition. Pass { type: "none" } to clear the goal.'),
15593
+ hidden: external_exports.boolean().optional().describe("Hide or unhide the category"),
15594
+ note: external_exports.string().optional().describe("New note (pass empty string to clear)")
14810
15595
  }),
14811
15596
  output: external_exports.object({
14812
- transaction: transactionSchema
15597
+ category: categorySchema
14813
15598
  }),
14814
15599
  handle: async (params) => {
14815
15600
  const planId = getPlanId();
14816
- const milliunits = Math.round(params.amount * 1e3);
14817
- const txId = crypto.randomUUID();
14818
- const transaction = {
14819
- id: txId,
14820
- entities_account_id: params.account_id,
14821
- date: params.date,
14822
- amount: milliunits,
14823
- cleared: params.cleared ?? "uncleared",
14824
- approved: params.approved ?? true,
14825
- memo: params.memo ?? null,
14826
- flag_color: params.flag_color ?? null,
14827
- entities_payee_id: params.payee_id ?? null,
14828
- payee_name: params.payee_name ?? null,
14829
- entities_subcategory_id: params.category_id ?? null,
14830
- is_tombstone: false
15601
+ const budget = await syncBudget(planId);
15602
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
15603
+ const existing2 = findCategory(budget.changed_entities, params.category_id);
15604
+ if (params.group_id) {
15605
+ assertCategoryGroupDeletable(findCategoryGroup(budget.changed_entities, params.group_id));
15606
+ }
15607
+ if (params.goal?.type === "debt" && existing2.entities_account_id == null) {
15608
+ throw ToolError.validation("Debt goals can only be set on debt-account categories.");
15609
+ }
15610
+ const updated = {
15611
+ ...existing2,
15612
+ name: params.name ?? existing2.name,
15613
+ entities_master_category_id: params.group_id ?? existing2.entities_master_category_id,
15614
+ is_hidden: params.hidden ?? existing2.is_hidden,
15615
+ note: params.note ?? existing2.note,
15616
+ ...params.goal !== void 0 ? buildGoalFields(params.goal) : {}
14831
15617
  };
14832
- const result = await syncWrite(planId, {
14833
- be_transactions: [transaction]
14834
- });
14835
- const saved = result.changed_entities?.be_transactions?.[0];
14836
- if (!saved) {
14837
- throw ToolError.internal("Transaction was created but no data was returned");
15618
+ await syncWrite(planId, { be_subcategories: [updated] }, serverKnowledge);
15619
+ return { category: mapCategory(updated) };
15620
+ }
15621
+ });
15622
+
15623
+ // src/tools/update-category-budget.ts
15624
+ var updateCategoryBudget = defineTool({
15625
+ name: "update_category_budget",
15626
+ displayName: "Update Category Budget",
15627
+ description: "Set the budgeted amount for a category in a specific month. Amount is in currency units (e.g. 500 to budget $500). The month should be in YYYY-MM format (e.g. 2026-03 for March 2026).",
15628
+ summary: "Set budgeted amount for a category",
15629
+ icon: "pencil",
15630
+ group: "Categories",
15631
+ input: external_exports.object({
15632
+ category_id: external_exports.string().min(1).describe("Category ID to budget"),
15633
+ month: external_exports.string().regex(/^\d{4}-\d{2}(-\d{2})?$/, "Month must be YYYY-MM or YYYY-MM-DD").describe("Month in YYYY-MM format (e.g. 2026-03)"),
15634
+ budgeted: external_exports.number().describe("Amount to budget in currency units (e.g. 500 for $500)")
15635
+ }),
15636
+ output: external_exports.object({
15637
+ category: categorySchema
15638
+ }),
15639
+ handle: async (params) => {
15640
+ const planId = getPlanId();
15641
+ const userId = getUserId();
15642
+ const milliunits = toMilliunits(params.budgeted);
15643
+ const monthKey = toMonthKey(params.month);
15644
+ const budgetId = formatSubcategoryBudgetId(monthKey, params.category_id);
15645
+ const monthlyBudgetId = formatMonthlyBudgetId(monthKey, planId);
15646
+ const budget = await syncBudget(planId);
15647
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
15648
+ const category = findCategory(budget.changed_entities, params.category_id);
15649
+ const existingBudget = (budget.changed_entities?.be_monthly_subcategory_budgets ?? []).find(
15650
+ (b) => b.id === budgetId && notTombstone(b)
15651
+ );
15652
+ const delta = milliunits - (existingBudget?.budgeted ?? 0);
15653
+ const budgetEntry = {
15654
+ id: budgetId,
15655
+ is_tombstone: false,
15656
+ entities_monthly_budget_id: monthlyBudgetId,
15657
+ entities_subcategory_id: params.category_id,
15658
+ budgeted: milliunits
15659
+ };
15660
+ const changedEntities = { be_monthly_subcategory_budgets: [budgetEntry] };
15661
+ if (delta !== 0) {
15662
+ changedEntities.be_money_movements = [
15663
+ {
15664
+ id: crypto.randomUUID(),
15665
+ is_tombstone: false,
15666
+ to_entities_monthly_subcategory_budget_id: delta > 0 ? budgetId : null,
15667
+ from_entities_monthly_subcategory_budget_id: delta < 0 ? budgetId : null,
15668
+ entities_money_movement_group_id: null,
15669
+ amount: Math.abs(delta),
15670
+ performed_by_user_id: userId,
15671
+ note: null,
15672
+ source: MONEY_MOVEMENT_SOURCE.ASSIGN,
15673
+ move_started_at: (/* @__PURE__ */ new Date()).toISOString(),
15674
+ move_accepted_at: null
15675
+ }
15676
+ ];
14838
15677
  }
14839
- return { transaction: mapTransaction(saved) };
15678
+ const result = await syncWrite(planId, changedEntities, serverKnowledge);
15679
+ const calcMap = buildSubcategoryCalcMap(result.changed_entities?.be_monthly_subcategory_budget_calculations ?? []);
15680
+ const budgetMap = buildSubcategoryBudgetMap(result.changed_entities?.be_monthly_subcategory_budgets ?? []);
15681
+ const key = `${monthKey}/${params.category_id}`;
15682
+ if (!budgetMap.has(key)) budgetMap.set(key, budgetEntry);
15683
+ return { category: mapCategoryForMonth(category, budgetMap, calcMap, monthKey) };
14840
15684
  }
14841
15685
  });
14842
15686
 
14843
15687
  // src/tools/update-transaction.ts
15688
+ var resolveFlag = (requested, existing2) => {
15689
+ if (requested === "none") return null;
15690
+ if (requested) return FLAG_MAP[requested];
15691
+ return existing2 ?? null;
15692
+ };
15693
+ var resolveCleared = (requested, existing2) => {
15694
+ if (requested) return CLEARED_MAP[requested];
15695
+ return existing2 ?? "Uncleared";
15696
+ };
14844
15697
  var updateTransaction = defineTool({
14845
15698
  name: "update_transaction",
14846
15699
  displayName: "Update Transaction",
14847
- description: "Update an existing transaction in the active YNAB plan. Only specified fields are changed; omitted fields remain unchanged. Amount is in currency units (negative for expenses, positive for income).",
15700
+ description: "Update an existing transaction in the active YNAB plan. Only specified fields are changed; omitted fields remain unchanged. Amount is in currency units (negative for expenses, positive for income). Transfers and split transactions cannot be updated through this tool \u2014 edit them directly in YNAB.",
14848
15701
  summary: "Update a transaction",
14849
15702
  icon: "pencil",
14850
15703
  group: "Transactions",
@@ -14859,175 +15712,85 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
14859
15712
  memo: external_exports.string().optional().describe("New transaction memo"),
14860
15713
  cleared: external_exports.enum(["cleared", "uncleared", "reconciled"]).optional().describe("New cleared status"),
14861
15714
  approved: external_exports.boolean().optional().describe("New approval status"),
14862
- flag_color: external_exports.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().describe("New flag color")
15715
+ flag_color: external_exports.enum(["red", "orange", "yellow", "green", "blue", "purple", "none"]).optional().describe('New flag color (pass "none" to clear)')
14863
15716
  }),
14864
15717
  output: external_exports.object({
14865
15718
  transaction: transactionSchema
14866
15719
  }),
14867
15720
  handle: async (params) => {
14868
15721
  const planId = getPlanId();
14869
- const transaction = {
14870
- id: params.transaction_id,
14871
- entities_account_id: params.account_id
14872
- };
14873
- if (params.date !== void 0) transaction.date = params.date;
14874
- if (params.amount !== void 0) transaction.amount = Math.round(params.amount * 1e3);
14875
- if (params.payee_name !== void 0) transaction.payee_name = params.payee_name;
14876
- if (params.payee_id !== void 0) transaction.entities_payee_id = params.payee_id;
14877
- if (params.category_id !== void 0) transaction.entities_subcategory_id = params.category_id;
14878
- if (params.memo !== void 0) transaction.memo = params.memo;
14879
- if (params.cleared !== void 0) transaction.cleared = params.cleared;
14880
- if (params.approved !== void 0) transaction.approved = params.approved;
14881
- if (params.flag_color !== void 0) transaction.flag_color = params.flag_color;
14882
- const result = await syncWrite(planId, {
14883
- be_transactions: [transaction]
14884
- });
14885
- const saved = result.changed_entities?.be_transactions?.[0];
14886
- if (!saved) {
14887
- throw ToolError.internal("Transaction was updated but no data was returned");
15722
+ const budget = await syncBudget(planId);
15723
+ const serverKnowledge = budget.current_server_knowledge ?? 0;
15724
+ const lookups = buildLookups(budget.changed_entities ?? {});
15725
+ const existing2 = budget.changed_entities?.be_transactions?.find(
15726
+ (t) => t.id === params.transaction_id && !t.is_tombstone
15727
+ );
15728
+ if (!existing2) {
15729
+ throw ToolError.notFound(`Transaction not found: ${params.transaction_id}`);
14888
15730
  }
14889
- return { transaction: mapTransaction(saved) };
14890
- }
14891
- });
14892
-
14893
- // src/tools/delete-transaction.ts
14894
- var deleteTransaction = defineTool({
14895
- name: "delete_transaction",
14896
- displayName: "Delete Transaction",
14897
- description: "Delete a transaction from the active YNAB plan. This marks the transaction as deleted (soft delete).",
14898
- summary: "Delete a transaction",
14899
- icon: "trash-2",
14900
- group: "Transactions",
14901
- input: external_exports.object({
14902
- transaction_id: external_exports.string().min(1).describe("Transaction ID to delete"),
14903
- account_id: external_exports.string().min(1).describe("Account ID the transaction belongs to")
14904
- }),
14905
- output: external_exports.object({
14906
- success: external_exports.boolean().describe("Whether the operation succeeded")
14907
- }),
14908
- handle: async (params) => {
14909
- const planId = getPlanId();
14910
- await syncWrite(planId, {
14911
- be_transactions: [
14912
- {
15731
+ if (existing2.transfer_account_id) {
15732
+ throw ToolError.validation("Cannot update transfer transactions \u2014 edit them in YNAB directly.");
15733
+ }
15734
+ const hasSubtransactions = (budget.changed_entities?.be_subtransactions ?? []).some(
15735
+ (s) => s.entities_transaction_id === params.transaction_id && !s.is_tombstone
15736
+ );
15737
+ if (hasSubtransactions) {
15738
+ throw ToolError.validation("Cannot update split transactions \u2014 edit them in YNAB directly.");
15739
+ }
15740
+ const changedEntities = {};
15741
+ let payeeId = params.payee_id ?? existing2.entities_payee_id ?? null;
15742
+ if (params.payee_name && !params.payee_id) {
15743
+ const resolved = resolvePayee(budget.changed_entities?.be_payees ?? [], params.payee_name);
15744
+ payeeId = resolved.payeeId;
15745
+ if (resolved.newPayee) {
15746
+ changedEntities.be_payees = [resolved.newPayee];
15747
+ lookups.payees.set(resolved.payeeId, params.payee_name);
15748
+ }
15749
+ }
15750
+ changedEntities.be_transaction_groups = [
15751
+ {
15752
+ id: params.transaction_id,
15753
+ be_transaction: {
14913
15754
  id: params.transaction_id,
15755
+ is_tombstone: false,
14914
15756
  entities_account_id: params.account_id,
14915
- is_tombstone: true
14916
- }
14917
- ]
14918
- });
14919
- return { success: true };
14920
- }
14921
- });
14922
-
14923
- // src/tools/list-scheduled-transactions.ts
14924
- var listScheduledTransactions = defineTool({
14925
- name: "list_scheduled_transactions",
14926
- displayName: "List Scheduled Transactions",
14927
- description: "List all scheduled (recurring) transactions in the active YNAB plan. Returns frequency, next occurrence date, amount, payee, and category for each.",
14928
- summary: "List scheduled/recurring transactions",
14929
- icon: "clock",
14930
- group: "Transactions",
14931
- input: external_exports.object({}),
14932
- output: external_exports.object({
14933
- scheduled_transactions: external_exports.array(scheduledTransactionSchema).describe("List of scheduled transactions")
14934
- }),
14935
- handle: async () => {
14936
- const planId = getPlanId();
14937
- const result = await syncBudget(planId);
14938
- const raw = result.changed_entities?.be_scheduled_transactions ?? [];
14939
- const scheduledTransactions = raw.filter((s) => !s.is_tombstone).map(mapScheduledTransaction).sort((a, b) => a.date_next.localeCompare(b.date_next));
14940
- return { scheduled_transactions: scheduledTransactions };
14941
- }
14942
- });
14943
-
14944
- // src/tools/list-months.ts
14945
- var listMonths = defineTool({
14946
- name: "list_months",
14947
- displayName: "List Months",
14948
- description: "List all budget months in the active YNAB plan. Returns income, budgeted, activity, and Ready to Assign amounts for each month. Sorted from most recent to oldest.",
14949
- summary: "List budget months with summaries",
14950
- icon: "calendar",
14951
- group: "Months",
14952
- input: external_exports.object({}),
14953
- output: external_exports.object({
14954
- months: external_exports.array(monthSchema).describe("List of budget months")
14955
- }),
14956
- handle: async () => {
14957
- const planId = getPlanId();
14958
- const result = await syncBudget(planId);
14959
- const entities = result.changed_entities;
14960
- const rawMonths = entities?.be_monthly_budgets ?? [];
14961
- const rawCalcs = entities?.be_monthly_budget_calculations ?? [];
14962
- const calcMap = /* @__PURE__ */ new Map();
14963
- for (const calc of rawCalcs) {
14964
- const budgetId = calc.entities_monthly_budget_id;
14965
- if (budgetId) {
14966
- const month = budgetId.replace("mb/", "");
14967
- calcMap.set(month, calc);
15757
+ entities_payee_id: payeeId,
15758
+ entities_subcategory_id: params.category_id ?? existing2.entities_subcategory_id ?? null,
15759
+ entities_scheduled_transaction_id: existing2.entities_scheduled_transaction_id ?? null,
15760
+ date: params.date ?? existing2.date ?? "",
15761
+ date_entered_from_schedule: null,
15762
+ amount: params.amount !== void 0 ? toMilliunits(params.amount) : existing2.amount ?? 0,
15763
+ cash_amount: 0,
15764
+ credit_amount: 0,
15765
+ credit_amount_adjusted: 0,
15766
+ subcategory_credit_amount_preceding: 0,
15767
+ memo: params.memo ?? existing2.memo ?? null,
15768
+ cleared: resolveCleared(params.cleared, existing2.cleared),
15769
+ // YNAB's wire format calls this "accepted"; the public tool surface uses "approved".
15770
+ accepted: params.approved ?? existing2.accepted ?? false,
15771
+ check_number: null,
15772
+ flag: resolveFlag(params.flag_color, existing2.flag),
15773
+ transfer_account_id: existing2.transfer_account_id ?? null,
15774
+ transfer_transaction_id: null,
15775
+ transfer_subtransaction_id: null,
15776
+ matched_transaction_id: null,
15777
+ ynab_id: existing2.ynab_id ?? null,
15778
+ imported_payee: existing2.imported_payee ?? null,
15779
+ imported_date: null,
15780
+ original_imported_payee: existing2.original_imported_payee ?? null,
15781
+ provider_cleansed_payee: null,
15782
+ source: existing2.source ?? null,
15783
+ debt_transaction_type: null
15784
+ },
15785
+ be_subtransactions: null
14968
15786
  }
15787
+ ];
15788
+ const result = await syncWrite(planId, changedEntities, serverKnowledge);
15789
+ const saved = result.changed_entities?.be_transactions?.find((t) => t.id === params.transaction_id);
15790
+ if (!saved) {
15791
+ throw ToolError.internal("Transaction was updated but no data was returned");
14969
15792
  }
14970
- const months = rawMonths.filter((m) => !m.is_tombstone).map((m) => mapMonth(m, calcMap.get(m.month ?? ""))).sort((a, b) => b.month.localeCompare(a.month));
14971
- return { months };
14972
- }
14973
- });
14974
-
14975
- // src/tools/get-month.ts
14976
- var getMonth = defineTool({
14977
- name: "get_month",
14978
- displayName: "Get Month",
14979
- description: "Get budget summary and category details for a specific month. Returns the month overview (income, budgeted, activity, Ready to Assign) plus per-category breakdowns. Month format is YYYY-MM-DD using the first of the month (e.g. 2026-03-01).",
14980
- summary: "Get budget details for a month",
14981
- icon: "calendar",
14982
- group: "Months",
14983
- input: external_exports.object({
14984
- month: external_exports.string().min(1).describe("Month in YYYY-MM-DD format (first of month, e.g. 2026-03-01)")
14985
- }),
14986
- output: external_exports.object({
14987
- month: monthSchema,
14988
- categories: external_exports.array(categorySchema).describe("Category budgets for this month")
14989
- }),
14990
- handle: async (params) => {
14991
- const planId = getPlanId();
14992
- const result = await syncBudget(planId);
14993
- const entities = result.changed_entities;
14994
- const rawMonths = entities?.be_monthly_budgets ?? [];
14995
- const monthData = rawMonths.find((m) => m.month === params.month && !m.is_tombstone);
14996
- if (!monthData) {
14997
- throw ToolError.notFound(`Month not found: ${params.month}`);
14998
- }
14999
- const monthlyCalcs = entities?.be_monthly_budget_calculations ?? [];
15000
- const monthCalc = monthlyCalcs.find((c) => {
15001
- const budgetId = c.entities_monthly_budget_id;
15002
- return budgetId && budgetId.replace("mb/", "") === params.month;
15003
- });
15004
- const rawCategories = (entities?.be_subcategories ?? []).filter((c) => !c.is_tombstone && c.is_hidden !== true);
15005
- const subcatCalcs = entities?.be_monthly_subcategory_budget_calculations ?? [];
15006
- const calcMap = /* @__PURE__ */ new Map();
15007
- for (const calc of subcatCalcs) {
15008
- const entityId = calc.entities_monthly_subcategory_budget_id;
15009
- if (entityId) {
15010
- const parts = entityId.split("/");
15011
- const categoryId = parts.length >= 3 ? parts.slice(2).join("/") : entityId;
15012
- calcMap.set(categoryId, calc);
15013
- }
15014
- }
15015
- const categories = rawCategories.map((c) => {
15016
- const calc = calcMap.get(c.id ?? "");
15017
- return mapCategory({
15018
- ...c,
15019
- budgeted: calc?.budgeted ?? c.budgeted,
15020
- activity: calc?.activity ?? c.activity,
15021
- balance: calc?.balance ?? c.balance,
15022
- goal_type: calc?.goal_type ?? c.goal_type,
15023
- goal_target: calc?.goal_target ?? c.goal_target,
15024
- goal_percentage_complete: calc?.goal_percentage_complete ?? c.goal_percentage_complete
15025
- });
15026
- });
15027
- return {
15028
- month: mapMonth(monthData, monthCalc),
15029
- categories
15030
- };
15793
+ return { transaction: mapTransaction(saved, lookups) };
15031
15794
  }
15032
15795
  });
15033
15796
 
@@ -15048,7 +15811,14 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
15048
15811
  getAccount,
15049
15812
  // Categories
15050
15813
  listCategories,
15814
+ createCategory,
15815
+ updateCategory,
15816
+ deleteCategory,
15817
+ createCategoryGroup,
15818
+ deleteCategoryGroup,
15051
15819
  updateCategoryBudget,
15820
+ moveCategoryBudget,
15821
+ snoozeCategoryGoal,
15052
15822
  // Payees
15053
15823
  listPayees,
15054
15824
  // Transactions
@@ -15069,7 +15839,7 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
15069
15839
  };
15070
15840
  var src_default = new YnabPlugin();
15071
15841
 
15072
- // dist/_adapter_entry_331af839-0591-41a5-afc4-b832c8b79610.ts
15842
+ // dist/_adapter_entry_0fc84288-9dca-44bb-92da-639aeea53a88.ts
15073
15843
  if (!globalThis.__openTabs) {
15074
15844
  globalThis.__openTabs = {};
15075
15845
  } else {
@@ -15285,5 +16055,5 @@ Set the \`cycles\` parameter to \`"ref"\` to resolve cyclical schemas with defs.
15285
16055
  };
15286
16056
  delete src_default.onDeactivate;
15287
16057
  }
15288
- })();(function(){var o=(globalThis).__openTabs;if(o&&o.adapters&&o.adapters["ynab"]){var a=o.adapters["ynab"];a.__adapterHash="0559e5ad8503b3f1094920c7c7fb557b8f9e39f79aa5dd2919a91ca6b0f29c1e";if(a.tools&&Array.isArray(a.tools)){for(var i=0;i<a.tools.length;i++){Object.freeze(a.tools[i]);}Object.freeze(a.tools);}Object.freeze(a);Object.defineProperty(o.adapters,"ynab",{value:a,writable:false,configurable:false,enumerable:true});Object.defineProperty(o,"adapters",{value:o.adapters,writable:false,configurable:false});}})();
16058
+ })();(function(){var o=(globalThis).__openTabs;if(o&&o.adapters&&o.adapters["ynab"]){var a=o.adapters["ynab"];a.__adapterHash="3a2d11d4883f6b89713b9ed3995d0c919e24e6ae5699ae84dc071e130223def6";if(a.tools&&Array.isArray(a.tools)){for(var i=0;i<a.tools.length;i++){Object.freeze(a.tools[i]);}Object.freeze(a.tools);}Object.freeze(a);Object.defineProperty(o.adapters,"ynab",{value:a,writable:false,configurable:false,enumerable:true});Object.defineProperty(o,"adapters",{value:o.adapters,writable:false,configurable:false});}})();
15289
16059
  //# sourceMappingURL=adapter.iife.js.map