@stephendolan/ynab-cli 2.6.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -187,6 +187,92 @@ function applyTransactionFilters(transactions, filters) {
187
187
  }
188
188
  return filtered;
189
189
  }
190
+ function summarizeTransactions(transactions, options) {
191
+ let totalAmount = 0;
192
+ const dates = [];
193
+ const byPayee = /* @__PURE__ */ new Map();
194
+ const byCategory = /* @__PURE__ */ new Map();
195
+ const byCleared = /* @__PURE__ */ new Map();
196
+ const byApproval = /* @__PURE__ */ new Map();
197
+ for (const t of transactions) {
198
+ totalAmount += t.amount;
199
+ dates.push(t.date);
200
+ const payeeKey = t.payee_id || t.payee_name || "(none)";
201
+ const payeeEntry = byPayee.get(payeeKey) || {
202
+ payee_id: t.payee_id || null,
203
+ payee_name: t.payee_name || null,
204
+ count: 0,
205
+ total_amount: 0
206
+ };
207
+ payeeEntry.count++;
208
+ payeeEntry.total_amount += t.amount;
209
+ byPayee.set(payeeKey, payeeEntry);
210
+ const catKey = t.category_id || t.category_name || "(uncategorized)";
211
+ const catEntry = byCategory.get(catKey) || {
212
+ category_id: t.category_id || null,
213
+ category_name: t.category_name || null,
214
+ count: 0,
215
+ total_amount: 0
216
+ };
217
+ catEntry.count++;
218
+ catEntry.total_amount += t.amount;
219
+ byCategory.set(catKey, catEntry);
220
+ const clearedEntry = byCleared.get(t.cleared) || { count: 0, total_amount: 0 };
221
+ clearedEntry.count++;
222
+ clearedEntry.total_amount += t.amount;
223
+ byCleared.set(t.cleared, clearedEntry);
224
+ const approvalKey = String(t.approved);
225
+ const approvalEntry = byApproval.get(approvalKey) || { count: 0, total_amount: 0 };
226
+ approvalEntry.count++;
227
+ approvalEntry.total_amount += t.amount;
228
+ byApproval.set(approvalKey, approvalEntry);
229
+ }
230
+ const sortByAbsAmount = (entries) => entries.sort((a, b) => Math.abs(b.total_amount) - Math.abs(a.total_amount));
231
+ const truncate = (entries, rollupFactory) => {
232
+ const top = options?.top;
233
+ if (!top || top <= 0 || entries.length <= top) return entries;
234
+ const kept = entries.slice(0, top);
235
+ const rest = entries.slice(top);
236
+ const rollup = rollupFactory({
237
+ count: rest.reduce((sum, e) => sum + e.count, 0),
238
+ total_amount: rest.reduce((sum, e) => sum + e.total_amount, 0)
239
+ });
240
+ return [...kept, rollup];
241
+ };
242
+ const payeeBreakdown = sortByAbsAmount([...byPayee.values()]);
243
+ const categoryBreakdown = sortByAbsAmount([...byCategory.values()]);
244
+ return {
245
+ total_count: transactions.length,
246
+ total_amount: totalAmount,
247
+ date_range: dates.length > 0 ? {
248
+ from: dates.reduce((a, b) => a < b ? a : b),
249
+ to: dates.reduce((a, b) => a > b ? a : b)
250
+ } : null,
251
+ by_payee: truncate(payeeBreakdown, (e) => ({ payee_id: null, payee_name: "(other)", ...e })),
252
+ by_category: truncate(categoryBreakdown, (e) => ({ category_id: null, category_name: "(other)", ...e })),
253
+ by_cleared_status: [...byCleared.entries()].map(([status, entry]) => ({ status, ...entry })),
254
+ by_approval_status: [...byApproval.entries()].map(([approved, entry]) => ({ approved: approved === "true", ...entry }))
255
+ };
256
+ }
257
+ function findTransferCandidates(source, allTransactions, options) {
258
+ const sourceAmount = Math.abs(source.amount);
259
+ const sourceDateMs = new Date(source.date).getTime();
260
+ const msPerDay = 864e5;
261
+ function daysBetween(t) {
262
+ return Math.abs(new Date(t.date).getTime() - sourceDateMs) / msPerDay;
263
+ }
264
+ return allTransactions.filter((t) => {
265
+ if (t.account_id === source.account_id) return false;
266
+ if (Math.abs(t.amount) !== sourceAmount) return false;
267
+ if (Math.sign(t.amount) === Math.sign(source.amount)) return false;
268
+ return daysBetween(t) <= options.maxDays;
269
+ }).map((t) => ({
270
+ transaction: t,
271
+ already_linked: !!t.transfer_transaction_id,
272
+ date_difference_days: Math.round(daysBetween(t)),
273
+ has_transfer_payee: !!t.payee_name?.startsWith("Transfer :")
274
+ })).sort((a, b) => a.date_difference_days - b.date_difference_days);
275
+ }
190
276
  function applyFieldSelection(items, fields) {
191
277
  if (!fields) return items;
192
278
  const fieldList = fields.split(",").map((f) => f.trim());
@@ -216,6 +302,7 @@ function outputJson(data, options = {}) {
216
302
 
217
303
  // src/commands/auth.ts
218
304
  import { Command } from "commander";
305
+ import { createInterface } from "readline";
219
306
 
220
307
  // src/lib/auth.ts
221
308
  import { Entry } from "@napi-rs/keyring";
@@ -593,6 +680,18 @@ var YnabClient = class {
593
680
  return response.data.transaction;
594
681
  });
595
682
  }
683
+ async updateTransactions(transactions, budgetId) {
684
+ return this.withErrorHandling(async () => {
685
+ const api = await this.getApi();
686
+ const id = await this.getBudgetId(budgetId);
687
+ const response = await api.transactions.updateTransactions(id, transactions);
688
+ return {
689
+ transactions: response.data.transactions,
690
+ transaction_ids: response.data.transaction_ids,
691
+ server_knowledge: response.data.server_knowledge
692
+ };
693
+ });
694
+ }
596
695
  async deleteTransaction(transactionId, budgetId) {
597
696
  return this.withErrorHandling(async () => {
598
697
  const api = await this.getApi();
@@ -705,11 +804,42 @@ function buildUpdateObject(options, mapping) {
705
804
  }
706
805
 
707
806
  // src/commands/auth.ts
807
+ function readTokenFromStdin() {
808
+ return new Promise((resolve, reject) => {
809
+ let data = "";
810
+ process.stdin.setEncoding("utf8");
811
+ process.stdin.on("data", (chunk) => {
812
+ data += chunk;
813
+ });
814
+ process.stdin.on("end", () => resolve(data.trim()));
815
+ process.stdin.on("error", reject);
816
+ });
817
+ }
818
+ function promptForToken() {
819
+ return new Promise((resolve) => {
820
+ const rl = createInterface({
821
+ input: process.stdin,
822
+ output: process.stderr
823
+ });
824
+ process.stderr.write("Enter YNAB Personal Access Token: ");
825
+ rl.question("", (answer) => {
826
+ rl.close();
827
+ resolve(answer.trim());
828
+ });
829
+ });
830
+ }
708
831
  function createAuthCommand() {
709
832
  const cmd = new Command("auth").description("Authentication management");
710
- cmd.command("login").description("Configure access token").requiredOption("-t, --token <token>", "YNAB Personal Access Token").action(
833
+ cmd.command("login").description("Configure access token").option("-t, --token <token>", "YNAB Personal Access Token").action(
711
834
  withErrorHandling(async (options) => {
712
- const token = options.token.trim();
835
+ let token;
836
+ if (options.token) {
837
+ token = options.token.trim();
838
+ } else if (!process.stdin.isTTY) {
839
+ token = await readTokenFromStdin();
840
+ } else {
841
+ token = await promptForToken();
842
+ }
713
843
  if (!token) {
714
844
  throw new YnabCliError("Access token cannot be empty", 400);
715
845
  }
@@ -836,7 +966,10 @@ function createAccountsCommand() {
836
966
  outputJson(account);
837
967
  })
838
968
  );
839
- cmd.command("transactions").description("List transactions for account").argument("<id>", "Account ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date").option("--type <type>", "Filter by transaction type").action(
969
+ cmd.command("transactions").description("List transactions for account").argument("<id>", "Account ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date").option("--type <type>", "Filter by transaction type").option(
970
+ "--fields <fields>",
971
+ "Comma-separated list of fields to include (e.g., id,date,amount,memo)"
972
+ ).action(
840
973
  withErrorHandling(
841
974
  async (id, options) => {
842
975
  const result = await client.getTransactionsByAccount(id, {
@@ -844,7 +977,8 @@ function createAccountsCommand() {
844
977
  sinceDate: options.since ? parseDate(options.since) : void 0,
845
978
  type: options.type
846
979
  });
847
- outputJson(result?.transactions);
980
+ const transactions = result?.transactions || [];
981
+ outputJson(applyFieldSelection(transactions, options.fields));
848
982
  }
849
983
  )
850
984
  );
@@ -916,7 +1050,10 @@ function createCategoriesCommand() {
916
1050
  }
917
1051
  )
918
1052
  );
919
- cmd.command("transactions").description("List transactions for category").argument("<id>", "Category ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).action(
1053
+ cmd.command("transactions").description("List transactions for category").argument("<id>", "Category ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).option(
1054
+ "--fields <fields>",
1055
+ "Comma-separated list of fields to include (e.g., id,date,amount,memo)"
1056
+ ).action(
920
1057
  withErrorHandling(
921
1058
  async (id, options) => {
922
1059
  const result = await client.getTransactionsByCategory(id, {
@@ -925,7 +1062,8 @@ function createCategoriesCommand() {
925
1062
  type: options.type,
926
1063
  lastKnowledgeOfServer: options.lastKnowledge
927
1064
  });
928
- outputJson(result?.transactions);
1065
+ const transactions = result?.transactions || [];
1066
+ outputJson(applyFieldSelection(transactions, options.fields));
929
1067
  }
930
1068
  )
931
1069
  );
@@ -934,6 +1072,7 @@ function createCategoriesCommand() {
934
1072
 
935
1073
  // src/commands/transactions.ts
936
1074
  import { Command as Command6 } from "commander";
1075
+ import dayjs2 from "dayjs";
937
1076
 
938
1077
  // src/lib/schemas.ts
939
1078
  function validateTransactionSplits(data) {
@@ -956,6 +1095,44 @@ function validateTransactionSplits(data) {
956
1095
  };
957
1096
  });
958
1097
  }
1098
+ var BATCH_UPDATE_FIELDS = [
1099
+ "id",
1100
+ "import_id",
1101
+ "account_id",
1102
+ "date",
1103
+ "amount",
1104
+ "payee_id",
1105
+ "payee_name",
1106
+ "category_id",
1107
+ "memo",
1108
+ "cleared",
1109
+ "approved",
1110
+ "flag_color"
1111
+ ];
1112
+ function validateBatchUpdates(data) {
1113
+ if (!Array.isArray(data)) {
1114
+ throw new YnabCliError("Batch updates must be an array", 400);
1115
+ }
1116
+ return data.map((item, index) => {
1117
+ if (typeof item !== "object" || item === null) {
1118
+ throw new YnabCliError(`Update at index ${index} must be an object`, 400);
1119
+ }
1120
+ const update = item;
1121
+ if (!update.id && !update.import_id) {
1122
+ throw new YnabCliError(
1123
+ `Update at index ${index} must have either "id" or "import_id"`,
1124
+ 400
1125
+ );
1126
+ }
1127
+ const result = {};
1128
+ for (const field of BATCH_UPDATE_FIELDS) {
1129
+ if (update[field] !== void 0) {
1130
+ result[field] = update[field];
1131
+ }
1132
+ }
1133
+ return result;
1134
+ });
1135
+ }
959
1136
  function validateApiData(data) {
960
1137
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
961
1138
  throw new YnabCliError("API data must be an object", 400);
@@ -964,6 +1141,18 @@ function validateApiData(data) {
964
1141
  }
965
1142
 
966
1143
  // src/commands/transactions.ts
1144
+ async function fetchTransactions(options) {
1145
+ const params = {
1146
+ budgetId: options.budget,
1147
+ sinceDate: options.since ? parseDate(options.since) : void 0,
1148
+ type: options.type,
1149
+ lastKnowledgeOfServer: options.lastKnowledge
1150
+ };
1151
+ if (options.account) return client.getTransactionsByAccount(options.account, params);
1152
+ if (options.category) return client.getTransactionsByCategory(options.category, params);
1153
+ if (options.payee) return client.getTransactionsByPayee(options.payee, params);
1154
+ return client.getTransactions(params);
1155
+ }
967
1156
  function buildTransactionData(options) {
968
1157
  if (!options.account) {
969
1158
  throw new YnabCliError("--account is required in non-interactive mode", 400);
@@ -991,25 +1180,27 @@ function createTransactionsCommand() {
991
1180
  ).option("--min-amount <amount>", "Minimum amount in currency units (e.g., 10.50)", parseFloat).option("--max-amount <amount>", "Maximum amount in currency units (e.g., 100.00)", parseFloat).option(
992
1181
  "--fields <fields>",
993
1182
  "Comma-separated list of fields to include (e.g., id,date,amount,memo)"
994
- ).action(
1183
+ ).option("--last-knowledge <number>", "Last server knowledge for delta requests. When used, output includes server_knowledge.", parseInt).option("--limit <number>", "Maximum number of transactions to return", parseInt).action(
995
1184
  withErrorHandling(
996
1185
  async (options) => {
997
- const params = {
998
- budgetId: options.budget,
999
- sinceDate: options.since ? parseDate(options.since) : void 0,
1000
- type: options.type
1001
- };
1002
- const result = options.account ? await client.getTransactionsByAccount(options.account, params) : options.category ? await client.getTransactionsByCategory(options.category, params) : options.payee ? await client.getTransactionsByPayee(options.payee, params) : await client.getTransactions(params);
1186
+ const result = await fetchTransactions(options);
1003
1187
  const transactions = result?.transactions || [];
1004
- const filtered = applyTransactionFilters(transactions, {
1188
+ let filtered = applyTransactionFilters(transactions, {
1005
1189
  until: options.until ? parseDate(options.until) : void 0,
1006
1190
  approved: options.approved,
1007
1191
  status: options.status,
1008
1192
  minAmount: options.minAmount,
1009
1193
  maxAmount: options.maxAmount
1010
1194
  });
1195
+ if (options.limit && options.limit > 0) {
1196
+ filtered = filtered.slice(0, options.limit);
1197
+ }
1011
1198
  const selected = applyFieldSelection(filtered, options.fields);
1012
- outputJson(selected);
1199
+ if (options.lastKnowledge !== void 0) {
1200
+ outputJson({ transactions: selected, server_knowledge: result?.server_knowledge });
1201
+ } else {
1202
+ outputJson(selected);
1203
+ }
1013
1204
  }
1014
1205
  )
1015
1206
  );
@@ -1135,6 +1326,33 @@ function createTransactionsCommand() {
1135
1326
  }
1136
1327
  )
1137
1328
  );
1329
+ cmd.command("batch-update").description(
1330
+ "Update multiple transactions in a single API call. Amounts should be in dollars (e.g., -21.40)."
1331
+ ).requiredOption(
1332
+ "--transactions <json>",
1333
+ 'JSON array of transaction updates. Each must have "id" or "import_id". Example: [{"id": "tx1", "approved": true, "category_id": "cat1"}]'
1334
+ ).option("-b, --budget <id>", "Budget ID").action(
1335
+ withErrorHandling(
1336
+ async (options) => {
1337
+ let parsed;
1338
+ try {
1339
+ parsed = JSON.parse(options.transactions);
1340
+ } catch {
1341
+ throw new YnabCliError("Invalid JSON in --transactions parameter", 400);
1342
+ }
1343
+ const updates = validateBatchUpdates(parsed);
1344
+ const transactionsInMilliunits = updates.map((update) => ({
1345
+ ...update,
1346
+ ...update.amount !== void 0 ? { amount: amountToMilliunits(update.amount) } : {}
1347
+ }));
1348
+ const result = await client.updateTransactions(
1349
+ { transactions: transactionsInMilliunits },
1350
+ options.budget
1351
+ );
1352
+ outputJson(result);
1353
+ }
1354
+ )
1355
+ );
1138
1356
  cmd.command("search").description("Search transactions").option("-b, --budget <id>", "Budget ID").option("--memo <text>", "Search in memo field").option("--payee-name <name>", "Search in payee name").option("--amount <amount>", "Search for exact amount in currency units", parseFloat).option("--since <date>", "Search transactions since date").option("--until <date>", "Search transactions until date").option("--approved <value>", "Filter by approval status: true or false").option(
1139
1357
  "--status <statuses>",
1140
1358
  "Filter by cleared status: cleared, uncleared, reconciled (comma-separated)"
@@ -1177,6 +1395,49 @@ function createTransactionsCommand() {
1177
1395
  }
1178
1396
  )
1179
1397
  );
1398
+ cmd.command("summary").description("Summarize transactions with aggregate counts by payee, category, and status").option("-b, --budget <id>", "Budget ID").option("--account <id>", "Filter by account ID").option("--category <id>", "Filter by category ID").option("--payee <id>", "Filter by payee ID").option("--since <date>", "Filter transactions since date").option("--until <date>", "Filter transactions until date").option("--type <type>", "Filter by transaction type").option("--approved <value>", "Filter by approval status: true or false").option(
1399
+ "--status <statuses>",
1400
+ "Filter by cleared status: cleared, uncleared, reconciled (comma-separated)"
1401
+ ).option("--min-amount <amount>", "Minimum amount in currency units", parseFloat).option("--max-amount <amount>", "Maximum amount in currency units", parseFloat).option("--top <number>", "Limit payee/category breakdowns to top N entries", parseInt).action(
1402
+ withErrorHandling(
1403
+ async (options) => {
1404
+ const result = await fetchTransactions(options);
1405
+ const transactions = result?.transactions || [];
1406
+ const filtered = applyTransactionFilters(transactions, {
1407
+ until: options.until ? parseDate(options.until) : void 0,
1408
+ approved: options.approved,
1409
+ status: options.status,
1410
+ minAmount: options.minAmount,
1411
+ maxAmount: options.maxAmount
1412
+ });
1413
+ const summary = summarizeTransactions(
1414
+ filtered,
1415
+ options.top ? { top: options.top } : void 0
1416
+ );
1417
+ outputJson(summary);
1418
+ }
1419
+ )
1420
+ );
1421
+ cmd.command("find-transfers").description("Find candidate transfer matches for a transaction across accounts").argument("<id>", "Transaction ID").option("-b, --budget <id>", "Budget ID").option("--days <number>", "Maximum date difference in days (default: 3)", parseInt).option("--since <date>", "Search transactions since date (defaults to source date minus --days)").action(
1422
+ withErrorHandling(
1423
+ async (id, options) => {
1424
+ const maxDays = options.days ?? 3;
1425
+ const source = await client.getTransaction(id, options.budget);
1426
+ const sinceDate = options.since ? parseDate(options.since) : dayjs2(source.date).subtract(maxDays, "day").format("YYYY-MM-DD");
1427
+ const result = await client.getTransactions({
1428
+ budgetId: options.budget,
1429
+ sinceDate
1430
+ });
1431
+ const allTransactions = result?.transactions || [];
1432
+ const candidates = findTransferCandidates(
1433
+ source,
1434
+ allTransactions,
1435
+ { maxDays }
1436
+ );
1437
+ outputJson({ source, candidates });
1438
+ }
1439
+ )
1440
+ );
1180
1441
  return cmd;
1181
1442
  }
1182
1443
 
@@ -1255,7 +1516,10 @@ function createPayeesCommand() {
1255
1516
  outputJson(locations);
1256
1517
  })
1257
1518
  );
1258
- cmd.command("transactions").description("List transactions for payee").argument("<id>", "Payee ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).action(
1519
+ cmd.command("transactions").description("List transactions for payee").argument("<id>", "Payee ID").option("-b, --budget <id>", "Budget ID").option("--since <date>", "Filter transactions since date").option("--type <type>", "Filter by transaction type").option("--last-knowledge <number>", "Last knowledge of server", parseInt).option(
1520
+ "--fields <fields>",
1521
+ "Comma-separated list of fields to include (e.g., id,date,amount,memo)"
1522
+ ).action(
1259
1523
  withErrorHandling(
1260
1524
  async (id, options) => {
1261
1525
  const result = await client.getTransactionsByPayee(id, {
@@ -1264,7 +1528,8 @@ function createPayeesCommand() {
1264
1528
  type: options.type,
1265
1529
  lastKnowledgeOfServer: options.lastKnowledge
1266
1530
  });
1267
- outputJson(result?.transactions);
1531
+ const transactions = result?.transactions || [];
1532
+ outputJson(applyFieldSelection(transactions, options.fields));
1268
1533
  }
1269
1534
  )
1270
1535
  );
@@ -1348,6 +1613,9 @@ var toolRegistry = [
1348
1613
  { name: "update_transaction", description: "Update an existing transaction" },
1349
1614
  { name: "delete_transaction", description: "Delete a transaction" },
1350
1615
  { name: "import_transactions", description: "Trigger import of linked bank transactions" },
1616
+ { name: "batch_update_transactions", description: "Update multiple transactions in a single API call" },
1617
+ { name: "summarize_transactions", description: "Get aggregate summary of transactions by payee, category, and status" },
1618
+ { name: "find_transfer_candidates", description: "Find candidate transfer matches for a transaction across accounts" },
1351
1619
  { name: "list_transactions_by_account", description: "List transactions for a specific account" },
1352
1620
  { name: "list_transactions_by_category", description: "List transactions for a specific category" },
1353
1621
  { name: "list_transactions_by_payee", description: "List transactions for a specific payee" },
@@ -1460,9 +1728,18 @@ server.tool(
1460
1728
  {
1461
1729
  budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1462
1730
  sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
1463
- type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type")
1731
+ type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type"),
1732
+ lastKnowledgeOfServer: z.number().optional().describe("Delta sync: only return changes since this server knowledge value. Response includes server_knowledge for next call."),
1733
+ limit: z.number().optional().describe("Maximum number of transactions to return"),
1734
+ fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
1464
1735
  },
1465
- async ({ budgetId, sinceDate, type }) => currencyResponse(await client.getTransactions({ budgetId, sinceDate, type }))
1736
+ async ({ budgetId, sinceDate, type, lastKnowledgeOfServer, limit, fields }) => {
1737
+ const result = await client.getTransactions({ budgetId, sinceDate, type, lastKnowledgeOfServer });
1738
+ let transactions = result?.transactions || [];
1739
+ if (limit && limit > 0) transactions = transactions.slice(0, limit);
1740
+ const selected = fields ? applyFieldSelection(transactions, fields) : transactions;
1741
+ return currencyResponse({ transactions: selected, server_knowledge: result?.server_knowledge });
1742
+ }
1466
1743
  );
1467
1744
  server.tool(
1468
1745
  "get_transaction",
@@ -1548,15 +1825,111 @@ server.tool(
1548
1825
  { budgetId: z.string().optional().describe("Budget ID (uses default if not specified)") },
1549
1826
  async ({ budgetId }) => jsonResponse(await client.importTransactions(budgetId))
1550
1827
  );
1828
+ server.tool(
1829
+ "batch_update_transactions",
1830
+ "Update multiple transactions in a single API call. Amounts in dollars.",
1831
+ {
1832
+ transactions: z.array(z.object({
1833
+ id: z.string().optional().nullable().describe("Transaction ID (required if no import_id)"),
1834
+ import_id: z.string().optional().nullable().describe("Import ID (required if no id)"),
1835
+ account_id: z.string().optional().describe("Account ID"),
1836
+ date: z.string().optional().describe("Transaction date (YYYY-MM-DD)"),
1837
+ amount: z.number().optional().describe("Amount in dollars (negative for outflow)"),
1838
+ payee_id: z.string().optional().nullable().describe("Payee ID"),
1839
+ payee_name: z.string().optional().nullable().describe("Payee name"),
1840
+ category_id: z.string().optional().nullable().describe("Category ID"),
1841
+ memo: z.string().optional().nullable().describe("Transaction memo"),
1842
+ cleared: z.enum(["cleared", "uncleared", "reconciled"]).optional().describe("Cleared status"),
1843
+ approved: z.boolean().optional().describe("Whether the transaction is approved"),
1844
+ flag_color: z.enum(["red", "orange", "yellow", "green", "blue", "purple"]).optional().nullable().describe("Flag color")
1845
+ })).describe("Array of transaction updates"),
1846
+ budgetId: z.string().optional().describe("Budget ID (uses default if not specified)")
1847
+ },
1848
+ async ({ transactions, budgetId }) => {
1849
+ const transactionsInMilliunits = transactions.map((update) => ({
1850
+ ...update,
1851
+ ...update.amount !== void 0 ? { amount: amountToMilliunits(update.amount) } : {}
1852
+ }));
1853
+ return currencyResponse(
1854
+ await client.updateTransactions(
1855
+ { transactions: transactionsInMilliunits },
1856
+ budgetId
1857
+ )
1858
+ );
1859
+ }
1860
+ );
1861
+ server.tool(
1862
+ "summarize_transactions",
1863
+ "Get aggregate summary of transactions by payee, category, and status",
1864
+ {
1865
+ budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1866
+ sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
1867
+ untilDate: z.string().optional().describe("Only return transactions on or before this date (YYYY-MM-DD)"),
1868
+ type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type"),
1869
+ approved: z.enum(["true", "false"]).optional().describe("Filter by approval status"),
1870
+ status: z.string().optional().describe("Filter by cleared status: cleared, uncleared, reconciled (comma-separated)"),
1871
+ minAmount: z.number().optional().describe("Minimum amount in dollars"),
1872
+ maxAmount: z.number().optional().describe("Maximum amount in dollars"),
1873
+ top: z.number().optional().describe("Limit payee/category breakdowns to top N entries")
1874
+ },
1875
+ async ({ budgetId, sinceDate, untilDate, type, approved, status, minAmount, maxAmount, top }) => {
1876
+ const result = await client.getTransactions({ budgetId, sinceDate, type });
1877
+ const transactions = result?.transactions || [];
1878
+ const filtered = applyTransactionFilters(transactions, {
1879
+ until: untilDate,
1880
+ approved,
1881
+ status,
1882
+ minAmount,
1883
+ maxAmount
1884
+ });
1885
+ const summary = summarizeTransactions(
1886
+ filtered,
1887
+ top ? { top } : void 0
1888
+ );
1889
+ return currencyResponse(summary);
1890
+ }
1891
+ );
1892
+ server.tool(
1893
+ "find_transfer_candidates",
1894
+ "Find candidate transfer matches for a transaction across accounts",
1895
+ {
1896
+ transactionId: z.string().describe("Transaction ID to find transfers for"),
1897
+ budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1898
+ maxDays: z.number().optional().describe("Maximum date difference in days (default: 3)"),
1899
+ sinceDate: z.string().optional().describe("Search transactions since date (defaults to source date minus maxDays)")
1900
+ },
1901
+ async ({ transactionId, budgetId, maxDays: maxDaysParam, sinceDate }) => {
1902
+ const days = maxDaysParam ?? 3;
1903
+ const source = await client.getTransaction(transactionId, budgetId);
1904
+ if (!sinceDate) {
1905
+ const d = new Date(source.date);
1906
+ d.setDate(d.getDate() - days);
1907
+ sinceDate = d.toISOString().split("T")[0];
1908
+ }
1909
+ const result = await client.getTransactions({ budgetId, sinceDate });
1910
+ const allTransactions = result?.transactions || [];
1911
+ const candidates = findTransferCandidates(
1912
+ source,
1913
+ allTransactions,
1914
+ { maxDays: days }
1915
+ );
1916
+ return currencyResponse({ source, candidates });
1917
+ }
1918
+ );
1551
1919
  server.tool(
1552
1920
  "list_transactions_by_account",
1553
1921
  "List transactions for a specific account",
1554
1922
  {
1555
1923
  accountId: z.string().describe("Account ID"),
1556
1924
  budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1557
- sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)")
1925
+ sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
1926
+ fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
1558
1927
  },
1559
- async ({ accountId, budgetId, sinceDate }) => currencyResponse(await client.getTransactionsByAccount(accountId, { budgetId, sinceDate }))
1928
+ async ({ accountId, budgetId, sinceDate, fields }) => {
1929
+ const result = await client.getTransactionsByAccount(accountId, { budgetId, sinceDate });
1930
+ if (!fields) return currencyResponse(result);
1931
+ return currencyResponse(applyFieldSelection(result?.transactions || [], fields));
1932
+ }
1560
1933
  );
1561
1934
  server.tool(
1562
1935
  "list_transactions_by_category",
@@ -1564,9 +1937,14 @@ server.tool(
1564
1937
  {
1565
1938
  categoryId: z.string().describe("Category ID"),
1566
1939
  budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1567
- sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)")
1940
+ sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
1941
+ fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
1568
1942
  },
1569
- async ({ categoryId, budgetId, sinceDate }) => currencyResponse(await client.getTransactionsByCategory(categoryId, { budgetId, sinceDate }))
1943
+ async ({ categoryId, budgetId, sinceDate, fields }) => {
1944
+ const result = await client.getTransactionsByCategory(categoryId, { budgetId, sinceDate });
1945
+ if (!fields) return currencyResponse(result);
1946
+ return currencyResponse(applyFieldSelection(result?.transactions || [], fields));
1947
+ }
1570
1948
  );
1571
1949
  server.tool(
1572
1950
  "list_transactions_by_payee",
@@ -1574,9 +1952,14 @@ server.tool(
1574
1952
  {
1575
1953
  payeeId: z.string().describe("Payee ID"),
1576
1954
  budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1577
- sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)")
1955
+ sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
1956
+ fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
1578
1957
  },
1579
- async ({ payeeId, budgetId, sinceDate }) => currencyResponse(await client.getTransactionsByPayee(payeeId, { budgetId, sinceDate }))
1958
+ async ({ payeeId, budgetId, sinceDate, fields }) => {
1959
+ const result = await client.getTransactionsByPayee(payeeId, { budgetId, sinceDate });
1960
+ if (!fields) return currencyResponse(result);
1961
+ return currencyResponse(applyFieldSelection(result?.transactions || [], fields));
1962
+ }
1580
1963
  );
1581
1964
  server.tool(
1582
1965
  "list_payees",
@@ -1697,7 +2080,7 @@ function createMcpCommand() {
1697
2080
 
1698
2081
  // src/cli.ts
1699
2082
  var program = new Command12();
1700
- program.name("ynab").description("A command-line interface for You Need a Budget (YNAB)").version("2.6.0").option("-c, --compact", "Minified JSON output (single line)").hook("preAction", (thisCommand) => {
2083
+ program.name("ynab").description("A command-line interface for You Need a Budget (YNAB)").version("2.8.0").option("-c, --compact", "Minified JSON output (single line)").hook("preAction", (thisCommand) => {
1701
2084
  const options = thisCommand.opts();
1702
2085
  setOutputOptions({
1703
2086
  compact: options.compact