@stephendolan/ynab-cli 2.7.0 → 2.8.1

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());
@@ -352,338 +438,271 @@ var YnabClient = class {
352
438
  }
353
439
  return budgetId;
354
440
  }
355
- async withErrorHandling(fn) {
356
- try {
357
- return await fn();
358
- } catch (error) {
359
- handleYnabError(error);
360
- }
361
- }
362
441
  async getUser() {
363
- return this.withErrorHandling(async () => {
364
- const api = await this.getApi();
365
- const response = await api.user.getUser();
366
- return response.data.user;
367
- });
442
+ const api = await this.getApi();
443
+ const response = await api.user.getUser();
444
+ return response.data.user;
368
445
  }
369
446
  async getBudgets(includeAccounts = false) {
370
- return this.withErrorHandling(async () => {
371
- const api = await this.getApi();
372
- const response = await api.budgets.getBudgets(includeAccounts);
373
- return {
374
- budgets: response.data.budgets,
375
- server_knowledge: 0
376
- };
377
- });
447
+ const api = await this.getApi();
448
+ const response = await api.budgets.getBudgets(includeAccounts);
449
+ return {
450
+ budgets: response.data.budgets,
451
+ server_knowledge: 0
452
+ };
378
453
  }
379
454
  async getBudget(budgetId, lastKnowledgeOfServer) {
380
- return this.withErrorHandling(async () => {
381
- const api = await this.getApi();
382
- const id = await this.getBudgetId(budgetId);
383
- const response = await api.budgets.getBudgetById(id, lastKnowledgeOfServer);
384
- return {
385
- budget: response.data.budget,
386
- server_knowledge: response.data.server_knowledge
387
- };
388
- });
455
+ const api = await this.getApi();
456
+ const id = await this.getBudgetId(budgetId);
457
+ const response = await api.budgets.getBudgetById(id, lastKnowledgeOfServer);
458
+ return {
459
+ budget: response.data.budget,
460
+ server_knowledge: response.data.server_knowledge
461
+ };
389
462
  }
390
463
  async getBudgetSettings(budgetId) {
391
- return this.withErrorHandling(async () => {
392
- const api = await this.getApi();
393
- const id = await this.getBudgetId(budgetId);
394
- const response = await api.budgets.getBudgetSettingsById(id);
395
- return response.data.settings;
396
- });
464
+ const api = await this.getApi();
465
+ const id = await this.getBudgetId(budgetId);
466
+ const response = await api.budgets.getBudgetSettingsById(id);
467
+ return response.data.settings;
397
468
  }
398
469
  async getAccounts(budgetId, lastKnowledgeOfServer) {
399
- return this.withErrorHandling(async () => {
400
- const api = await this.getApi();
401
- const id = await this.getBudgetId(budgetId);
402
- const response = await api.accounts.getAccounts(id, lastKnowledgeOfServer);
403
- return {
404
- accounts: response.data.accounts,
405
- server_knowledge: response.data.server_knowledge
406
- };
407
- });
470
+ const api = await this.getApi();
471
+ const id = await this.getBudgetId(budgetId);
472
+ const response = await api.accounts.getAccounts(id, lastKnowledgeOfServer);
473
+ return {
474
+ accounts: response.data.accounts,
475
+ server_knowledge: response.data.server_knowledge
476
+ };
408
477
  }
409
478
  async getAccount(accountId, budgetId) {
410
- return this.withErrorHandling(async () => {
411
- const api = await this.getApi();
412
- const id = await this.getBudgetId(budgetId);
413
- const response = await api.accounts.getAccountById(id, accountId);
414
- return response.data.account;
415
- });
479
+ const api = await this.getApi();
480
+ const id = await this.getBudgetId(budgetId);
481
+ const response = await api.accounts.getAccountById(id, accountId);
482
+ return response.data.account;
416
483
  }
417
484
  async getCategories(budgetId, lastKnowledgeOfServer) {
418
- return this.withErrorHandling(async () => {
419
- const api = await this.getApi();
420
- const id = await this.getBudgetId(budgetId);
421
- const response = await api.categories.getCategories(id, lastKnowledgeOfServer);
422
- return {
423
- category_groups: response.data.category_groups,
424
- server_knowledge: response.data.server_knowledge
425
- };
426
- });
485
+ const api = await this.getApi();
486
+ const id = await this.getBudgetId(budgetId);
487
+ const response = await api.categories.getCategories(id, lastKnowledgeOfServer);
488
+ return {
489
+ category_groups: response.data.category_groups,
490
+ server_knowledge: response.data.server_knowledge
491
+ };
427
492
  }
428
493
  async getCategory(categoryId, budgetId) {
429
- return this.withErrorHandling(async () => {
430
- const api = await this.getApi();
431
- const id = await this.getBudgetId(budgetId);
432
- const response = await api.categories.getCategoryById(id, categoryId);
433
- return response.data.category;
434
- });
494
+ const api = await this.getApi();
495
+ const id = await this.getBudgetId(budgetId);
496
+ const response = await api.categories.getCategoryById(id, categoryId);
497
+ return response.data.category;
435
498
  }
436
499
  async updateMonthCategory(month, categoryId, data, budgetId) {
437
- return this.withErrorHandling(async () => {
438
- const api = await this.getApi();
439
- const id = await this.getBudgetId(budgetId);
440
- const response = await api.categories.updateMonthCategory(id, month, categoryId, data);
441
- return response.data.category;
442
- });
500
+ const api = await this.getApi();
501
+ const id = await this.getBudgetId(budgetId);
502
+ const response = await api.categories.updateMonthCategory(id, month, categoryId, data);
503
+ return response.data.category;
443
504
  }
444
505
  async updateCategory(categoryId, data, budgetId) {
445
- return this.withErrorHandling(async () => {
446
- const api = await this.getApi();
447
- const id = await this.getBudgetId(budgetId);
448
- const response = await api.categories.updateCategory(id, categoryId, data);
449
- return response.data.category;
450
- });
506
+ const api = await this.getApi();
507
+ const id = await this.getBudgetId(budgetId);
508
+ const response = await api.categories.updateCategory(id, categoryId, data);
509
+ return response.data.category;
451
510
  }
452
511
  async getPayees(budgetId, lastKnowledgeOfServer) {
453
- return this.withErrorHandling(async () => {
454
- const api = await this.getApi();
455
- const id = await this.getBudgetId(budgetId);
456
- const response = await api.payees.getPayees(id, lastKnowledgeOfServer);
457
- return {
458
- payees: response.data.payees,
459
- server_knowledge: response.data.server_knowledge
460
- };
461
- });
512
+ const api = await this.getApi();
513
+ const id = await this.getBudgetId(budgetId);
514
+ const response = await api.payees.getPayees(id, lastKnowledgeOfServer);
515
+ return {
516
+ payees: response.data.payees,
517
+ server_knowledge: response.data.server_knowledge
518
+ };
462
519
  }
463
520
  async getPayee(payeeId, budgetId) {
464
- return this.withErrorHandling(async () => {
465
- const api = await this.getApi();
466
- const id = await this.getBudgetId(budgetId);
467
- const response = await api.payees.getPayeeById(id, payeeId);
468
- return response.data.payee;
469
- });
521
+ const api = await this.getApi();
522
+ const id = await this.getBudgetId(budgetId);
523
+ const response = await api.payees.getPayeeById(id, payeeId);
524
+ return response.data.payee;
470
525
  }
471
526
  async updatePayee(payeeId, data, budgetId) {
472
- return this.withErrorHandling(async () => {
473
- const api = await this.getApi();
474
- const id = await this.getBudgetId(budgetId);
475
- const response = await api.payees.updatePayee(id, payeeId, data);
476
- return response.data.payee;
477
- });
527
+ const api = await this.getApi();
528
+ const id = await this.getBudgetId(budgetId);
529
+ const response = await api.payees.updatePayee(id, payeeId, data);
530
+ return response.data.payee;
478
531
  }
479
532
  async getPayeeLocationsByPayee(payeeId, budgetId) {
480
- return this.withErrorHandling(async () => {
481
- const api = await this.getApi();
482
- const id = await this.getBudgetId(budgetId);
483
- const response = await api.payeeLocations.getPayeeLocationsByPayee(id, payeeId);
484
- return response.data.payee_locations;
485
- });
533
+ const api = await this.getApi();
534
+ const id = await this.getBudgetId(budgetId);
535
+ const response = await api.payeeLocations.getPayeeLocationsByPayee(id, payeeId);
536
+ return response.data.payee_locations;
486
537
  }
487
538
  async getBudgetMonths(budgetId, lastKnowledgeOfServer) {
488
- return this.withErrorHandling(async () => {
489
- const api = await this.getApi();
490
- const id = await this.getBudgetId(budgetId);
491
- const response = await api.months.getBudgetMonths(id, lastKnowledgeOfServer);
492
- return {
493
- months: response.data.months,
494
- server_knowledge: response.data.server_knowledge
495
- };
496
- });
539
+ const api = await this.getApi();
540
+ const id = await this.getBudgetId(budgetId);
541
+ const response = await api.months.getBudgetMonths(id, lastKnowledgeOfServer);
542
+ return {
543
+ months: response.data.months,
544
+ server_knowledge: response.data.server_knowledge
545
+ };
497
546
  }
498
547
  async getBudgetMonth(month, budgetId) {
499
- return this.withErrorHandling(async () => {
500
- const api = await this.getApi();
501
- const id = await this.getBudgetId(budgetId);
502
- const response = await api.months.getBudgetMonth(id, month);
503
- return response.data.month;
504
- });
548
+ const api = await this.getApi();
549
+ const id = await this.getBudgetId(budgetId);
550
+ const response = await api.months.getBudgetMonth(id, month);
551
+ return response.data.month;
505
552
  }
506
553
  async getTransactions(params) {
507
- return this.withErrorHandling(async () => {
508
- const api = await this.getApi();
509
- const id = await this.getBudgetId(params.budgetId);
510
- const response = await api.transactions.getTransactions(
511
- id,
512
- params.sinceDate,
513
- params.type,
514
- params.lastKnowledgeOfServer
515
- );
516
- return {
517
- transactions: response.data.transactions,
518
- server_knowledge: response.data.server_knowledge
519
- };
520
- });
554
+ const api = await this.getApi();
555
+ const id = await this.getBudgetId(params.budgetId);
556
+ const response = await api.transactions.getTransactions(
557
+ id,
558
+ params.sinceDate,
559
+ params.type,
560
+ params.lastKnowledgeOfServer
561
+ );
562
+ return {
563
+ transactions: response.data.transactions,
564
+ server_knowledge: response.data.server_knowledge
565
+ };
521
566
  }
522
567
  async getTransactionsByAccount(accountId, params) {
523
- return this.withErrorHandling(async () => {
524
- const api = await this.getApi();
525
- const id = await this.getBudgetId(params.budgetId);
526
- const response = await api.transactions.getTransactionsByAccount(
527
- id,
528
- accountId,
529
- params.sinceDate,
530
- params.type,
531
- params.lastKnowledgeOfServer
532
- );
533
- return {
534
- transactions: response.data.transactions,
535
- server_knowledge: response.data.server_knowledge
536
- };
537
- });
568
+ const api = await this.getApi();
569
+ const id = await this.getBudgetId(params.budgetId);
570
+ const response = await api.transactions.getTransactionsByAccount(
571
+ id,
572
+ accountId,
573
+ params.sinceDate,
574
+ params.type,
575
+ params.lastKnowledgeOfServer
576
+ );
577
+ return {
578
+ transactions: response.data.transactions,
579
+ server_knowledge: response.data.server_knowledge
580
+ };
538
581
  }
539
582
  async getTransactionsByCategory(categoryId, params) {
540
- return this.withErrorHandling(async () => {
541
- const api = await this.getApi();
542
- const id = await this.getBudgetId(params.budgetId);
543
- const response = await api.transactions.getTransactionsByCategory(
544
- id,
545
- categoryId,
546
- params.sinceDate,
547
- params.type,
548
- params.lastKnowledgeOfServer
549
- );
550
- return {
551
- transactions: response.data.transactions,
552
- server_knowledge: response.data.server_knowledge
553
- };
554
- });
583
+ const api = await this.getApi();
584
+ const id = await this.getBudgetId(params.budgetId);
585
+ const response = await api.transactions.getTransactionsByCategory(
586
+ id,
587
+ categoryId,
588
+ params.sinceDate,
589
+ params.type,
590
+ params.lastKnowledgeOfServer
591
+ );
592
+ return {
593
+ transactions: response.data.transactions,
594
+ server_knowledge: response.data.server_knowledge
595
+ };
555
596
  }
556
597
  async getTransactionsByPayee(payeeId, params) {
557
- return this.withErrorHandling(async () => {
558
- const api = await this.getApi();
559
- const id = await this.getBudgetId(params.budgetId);
560
- const response = await api.transactions.getTransactionsByPayee(
561
- id,
562
- payeeId,
563
- params.sinceDate,
564
- params.type,
565
- params.lastKnowledgeOfServer
566
- );
567
- return {
568
- transactions: response.data.transactions,
569
- server_knowledge: response.data.server_knowledge
570
- };
571
- });
598
+ const api = await this.getApi();
599
+ const id = await this.getBudgetId(params.budgetId);
600
+ const response = await api.transactions.getTransactionsByPayee(
601
+ id,
602
+ payeeId,
603
+ params.sinceDate,
604
+ params.type,
605
+ params.lastKnowledgeOfServer
606
+ );
607
+ return {
608
+ transactions: response.data.transactions,
609
+ server_knowledge: response.data.server_knowledge
610
+ };
572
611
  }
573
612
  async getTransaction(transactionId, budgetId) {
574
- return this.withErrorHandling(async () => {
575
- const api = await this.getApi();
576
- const id = await this.getBudgetId(budgetId);
577
- const response = await api.transactions.getTransactionById(id, transactionId);
578
- return response.data.transaction;
579
- });
613
+ const api = await this.getApi();
614
+ const id = await this.getBudgetId(budgetId);
615
+ const response = await api.transactions.getTransactionById(id, transactionId);
616
+ return response.data.transaction;
580
617
  }
581
618
  async createTransaction(transactionData, budgetId) {
582
- return this.withErrorHandling(async () => {
583
- const api = await this.getApi();
584
- const id = await this.getBudgetId(budgetId);
585
- const response = await api.transactions.createTransaction(id, transactionData);
586
- return response.data.transaction;
587
- });
619
+ const api = await this.getApi();
620
+ const id = await this.getBudgetId(budgetId);
621
+ const response = await api.transactions.createTransaction(id, transactionData);
622
+ return response.data.transaction;
588
623
  }
589
624
  async updateTransaction(transactionId, transactionData, budgetId) {
590
- return this.withErrorHandling(async () => {
591
- const api = await this.getApi();
592
- const id = await this.getBudgetId(budgetId);
593
- const response = await api.transactions.updateTransaction(id, transactionId, transactionData);
594
- return response.data.transaction;
595
- });
625
+ const api = await this.getApi();
626
+ const id = await this.getBudgetId(budgetId);
627
+ const response = await api.transactions.updateTransaction(id, transactionId, transactionData);
628
+ return response.data.transaction;
596
629
  }
597
630
  async updateTransactions(transactions, budgetId) {
598
- return this.withErrorHandling(async () => {
599
- const api = await this.getApi();
600
- const id = await this.getBudgetId(budgetId);
601
- const response = await api.transactions.updateTransactions(id, transactions);
602
- return {
603
- transactions: response.data.transactions,
604
- transaction_ids: response.data.transaction_ids,
605
- server_knowledge: response.data.server_knowledge
606
- };
607
- });
631
+ const api = await this.getApi();
632
+ const id = await this.getBudgetId(budgetId);
633
+ const response = await api.transactions.updateTransactions(id, transactions);
634
+ return {
635
+ transactions: response.data.transactions,
636
+ transaction_ids: response.data.transaction_ids,
637
+ server_knowledge: response.data.server_knowledge
638
+ };
608
639
  }
609
640
  async deleteTransaction(transactionId, budgetId) {
610
- return this.withErrorHandling(async () => {
611
- const api = await this.getApi();
612
- const id = await this.getBudgetId(budgetId);
613
- const response = await api.transactions.deleteTransaction(id, transactionId);
614
- return response.data.transaction;
615
- });
641
+ const api = await this.getApi();
642
+ const id = await this.getBudgetId(budgetId);
643
+ const response = await api.transactions.deleteTransaction(id, transactionId);
644
+ return response.data.transaction;
616
645
  }
617
646
  async importTransactions(budgetId) {
618
- return this.withErrorHandling(async () => {
619
- const api = await this.getApi();
620
- const id = await this.getBudgetId(budgetId);
621
- const response = await api.transactions.importTransactions(id);
622
- return response.data.transaction_ids;
623
- });
647
+ const api = await this.getApi();
648
+ const id = await this.getBudgetId(budgetId);
649
+ const response = await api.transactions.importTransactions(id);
650
+ return response.data.transaction_ids;
624
651
  }
625
652
  async getScheduledTransactions(budgetId, lastKnowledgeOfServer) {
626
- return this.withErrorHandling(async () => {
627
- const api = await this.getApi();
628
- const id = await this.getBudgetId(budgetId);
629
- const response = await api.scheduledTransactions.getScheduledTransactions(
630
- id,
631
- lastKnowledgeOfServer
632
- );
633
- return {
634
- scheduled_transactions: response.data.scheduled_transactions,
635
- server_knowledge: response.data.server_knowledge
636
- };
637
- });
653
+ const api = await this.getApi();
654
+ const id = await this.getBudgetId(budgetId);
655
+ const response = await api.scheduledTransactions.getScheduledTransactions(
656
+ id,
657
+ lastKnowledgeOfServer
658
+ );
659
+ return {
660
+ scheduled_transactions: response.data.scheduled_transactions,
661
+ server_knowledge: response.data.server_knowledge
662
+ };
638
663
  }
639
664
  async getScheduledTransaction(scheduledTransactionId, budgetId) {
640
- return this.withErrorHandling(async () => {
641
- const api = await this.getApi();
642
- const id = await this.getBudgetId(budgetId);
643
- const response = await api.scheduledTransactions.getScheduledTransactionById(
644
- id,
645
- scheduledTransactionId
646
- );
647
- return response.data.scheduled_transaction;
648
- });
665
+ const api = await this.getApi();
666
+ const id = await this.getBudgetId(budgetId);
667
+ const response = await api.scheduledTransactions.getScheduledTransactionById(
668
+ id,
669
+ scheduledTransactionId
670
+ );
671
+ return response.data.scheduled_transaction;
649
672
  }
650
673
  async deleteScheduledTransaction(scheduledTransactionId, budgetId) {
651
- return this.withErrorHandling(async () => {
652
- const api = await this.getApi();
653
- const id = await this.getBudgetId(budgetId);
654
- const response = await api.scheduledTransactions.deleteScheduledTransaction(
655
- id,
656
- scheduledTransactionId
657
- );
658
- return response.data.scheduled_transaction;
659
- });
674
+ const api = await this.getApi();
675
+ const id = await this.getBudgetId(budgetId);
676
+ const response = await api.scheduledTransactions.deleteScheduledTransaction(
677
+ id,
678
+ scheduledTransactionId
679
+ );
680
+ return response.data.scheduled_transaction;
660
681
  }
661
682
  async rawApiCall(method, path, data, budgetId) {
662
- return this.withErrorHandling(async () => {
663
- await this.getApi();
664
- const fullPath = path.includes("{budget_id}") ? path.replace("{budget_id}", await this.getBudgetId(budgetId)) : path;
665
- const url = `https://api.ynab.com/v1${fullPath}`;
666
- const accessToken = await auth.getAccessToken() || process.env.YNAB_API_KEY;
667
- const headers = {
668
- Authorization: `Bearer ${accessToken}`,
669
- "Content-Type": "application/json"
670
- };
671
- const httpMethod = method.toUpperCase();
672
- const hasBody = ["POST", "PUT", "PATCH"].includes(httpMethod);
673
- if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(httpMethod)) {
674
- throw new YnabCliError(`Unsupported HTTP method: ${method}`, 400);
675
- }
676
- const response = await fetch(url, {
677
- method: httpMethod,
678
- headers,
679
- ...hasBody && { body: JSON.stringify(data) }
680
- });
681
- if (!response.ok) {
682
- const errorData = await response.json();
683
- throw { error: sanitizeApiError(errorData.error || errorData) };
684
- }
685
- return await response.json();
683
+ await this.getApi();
684
+ const fullPath = path.includes("{budget_id}") ? path.replace("{budget_id}", await this.getBudgetId(budgetId)) : path;
685
+ const url = `https://api.ynab.com/v1${fullPath}`;
686
+ const accessToken = await auth.getAccessToken() || process.env.YNAB_API_KEY;
687
+ const headers = {
688
+ Authorization: `Bearer ${accessToken}`,
689
+ "Content-Type": "application/json"
690
+ };
691
+ const httpMethod = method.toUpperCase();
692
+ const hasBody = ["POST", "PUT", "PATCH"].includes(httpMethod);
693
+ if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(httpMethod)) {
694
+ throw new YnabCliError(`Unsupported HTTP method: ${method}`, 400);
695
+ }
696
+ const response = await fetch(url, {
697
+ method: httpMethod,
698
+ headers,
699
+ ...hasBody && { body: JSON.stringify(data) }
686
700
  });
701
+ if (!response.ok) {
702
+ const errorData = await response.json();
703
+ throw { error: sanitizeApiError(errorData.error || errorData) };
704
+ }
705
+ return await response.json();
687
706
  }
688
707
  };
689
708
  var client = new YnabClient();
@@ -986,6 +1005,7 @@ function createCategoriesCommand() {
986
1005
 
987
1006
  // src/commands/transactions.ts
988
1007
  import { Command as Command6 } from "commander";
1008
+ import dayjs2 from "dayjs";
989
1009
 
990
1010
  // src/lib/schemas.ts
991
1011
  function validateTransactionSplits(data) {
@@ -1054,6 +1074,18 @@ function validateApiData(data) {
1054
1074
  }
1055
1075
 
1056
1076
  // src/commands/transactions.ts
1077
+ async function fetchTransactions(options) {
1078
+ const params = {
1079
+ budgetId: options.budget,
1080
+ sinceDate: options.since ? parseDate(options.since) : void 0,
1081
+ type: options.type,
1082
+ lastKnowledgeOfServer: options.lastKnowledge
1083
+ };
1084
+ if (options.account) return client.getTransactionsByAccount(options.account, params);
1085
+ if (options.category) return client.getTransactionsByCategory(options.category, params);
1086
+ if (options.payee) return client.getTransactionsByPayee(options.payee, params);
1087
+ return client.getTransactions(params);
1088
+ }
1057
1089
  function buildTransactionData(options) {
1058
1090
  if (!options.account) {
1059
1091
  throw new YnabCliError("--account is required in non-interactive mode", 400);
@@ -1081,25 +1113,27 @@ function createTransactionsCommand() {
1081
1113
  ).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(
1082
1114
  "--fields <fields>",
1083
1115
  "Comma-separated list of fields to include (e.g., id,date,amount,memo)"
1084
- ).action(
1116
+ ).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(
1085
1117
  withErrorHandling(
1086
1118
  async (options) => {
1087
- const params = {
1088
- budgetId: options.budget,
1089
- sinceDate: options.since ? parseDate(options.since) : void 0,
1090
- type: options.type
1091
- };
1092
- 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);
1119
+ const result = await fetchTransactions(options);
1093
1120
  const transactions = result?.transactions || [];
1094
- const filtered = applyTransactionFilters(transactions, {
1121
+ let filtered = applyTransactionFilters(transactions, {
1095
1122
  until: options.until ? parseDate(options.until) : void 0,
1096
1123
  approved: options.approved,
1097
1124
  status: options.status,
1098
1125
  minAmount: options.minAmount,
1099
1126
  maxAmount: options.maxAmount
1100
1127
  });
1128
+ if (options.limit && options.limit > 0) {
1129
+ filtered = filtered.slice(0, options.limit);
1130
+ }
1101
1131
  const selected = applyFieldSelection(filtered, options.fields);
1102
- outputJson(selected);
1132
+ if (options.lastKnowledge !== void 0) {
1133
+ outputJson({ transactions: selected, server_knowledge: result?.server_knowledge });
1134
+ } else {
1135
+ outputJson(selected);
1136
+ }
1103
1137
  }
1104
1138
  )
1105
1139
  );
@@ -1294,6 +1328,49 @@ function createTransactionsCommand() {
1294
1328
  }
1295
1329
  )
1296
1330
  );
1331
+ 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(
1332
+ "--status <statuses>",
1333
+ "Filter by cleared status: cleared, uncleared, reconciled (comma-separated)"
1334
+ ).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(
1335
+ withErrorHandling(
1336
+ async (options) => {
1337
+ const result = await fetchTransactions(options);
1338
+ const transactions = result?.transactions || [];
1339
+ const filtered = applyTransactionFilters(transactions, {
1340
+ until: options.until ? parseDate(options.until) : void 0,
1341
+ approved: options.approved,
1342
+ status: options.status,
1343
+ minAmount: options.minAmount,
1344
+ maxAmount: options.maxAmount
1345
+ });
1346
+ const summary = summarizeTransactions(
1347
+ filtered,
1348
+ options.top ? { top: options.top } : void 0
1349
+ );
1350
+ outputJson(summary);
1351
+ }
1352
+ )
1353
+ );
1354
+ 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(
1355
+ withErrorHandling(
1356
+ async (id, options) => {
1357
+ const maxDays = options.days ?? 3;
1358
+ const source = await client.getTransaction(id, options.budget);
1359
+ const sinceDate = options.since ? parseDate(options.since) : dayjs2(source.date).subtract(maxDays, "day").format("YYYY-MM-DD");
1360
+ const result = await client.getTransactions({
1361
+ budgetId: options.budget,
1362
+ sinceDate
1363
+ });
1364
+ const allTransactions = result?.transactions || [];
1365
+ const candidates = findTransferCandidates(
1366
+ source,
1367
+ allTransactions,
1368
+ { maxDays }
1369
+ );
1370
+ outputJson({ source, candidates });
1371
+ }
1372
+ )
1373
+ );
1297
1374
  return cmd;
1298
1375
  }
1299
1376
 
@@ -1470,6 +1547,8 @@ var toolRegistry = [
1470
1547
  { name: "delete_transaction", description: "Delete a transaction" },
1471
1548
  { name: "import_transactions", description: "Trigger import of linked bank transactions" },
1472
1549
  { name: "batch_update_transactions", description: "Update multiple transactions in a single API call" },
1550
+ { name: "summarize_transactions", description: "Get aggregate summary of transactions by payee, category, and status" },
1551
+ { name: "find_transfer_candidates", description: "Find candidate transfer matches for a transaction across accounts" },
1473
1552
  { name: "list_transactions_by_account", description: "List transactions for a specific account" },
1474
1553
  { name: "list_transactions_by_category", description: "List transactions for a specific category" },
1475
1554
  { name: "list_transactions_by_payee", description: "List transactions for a specific payee" },
@@ -1489,6 +1568,37 @@ var server = new McpServer({
1489
1568
  name: "ynab",
1490
1569
  version: "1.0.0"
1491
1570
  });
1571
+ function mcpErrorResult(error) {
1572
+ let errorBody;
1573
+ if (typeof error === "object" && error !== null && "error" in error) {
1574
+ const apiError = sanitizeApiError(error.error);
1575
+ errorBody = { name: apiError.name, detail: apiError.detail };
1576
+ } else if (error instanceof YnabCliError) {
1577
+ errorBody = { name: "cli_error", detail: sanitizeErrorMessage(error.message) };
1578
+ } else if (error instanceof Error) {
1579
+ errorBody = { name: error.name, detail: sanitizeErrorMessage(error.message) };
1580
+ } else {
1581
+ errorBody = { name: "unknown_error", detail: "An unexpected error occurred" };
1582
+ }
1583
+ return {
1584
+ content: [{ type: "text", text: JSON.stringify({ error: errorBody }) }],
1585
+ isError: true
1586
+ };
1587
+ }
1588
+ var _serverTool = server.tool.bind(server);
1589
+ server.tool = (...args) => {
1590
+ const handler = args[args.length - 1];
1591
+ if (typeof handler === "function") {
1592
+ args[args.length - 1] = async (...handlerArgs) => {
1593
+ try {
1594
+ return await handler(...handlerArgs);
1595
+ } catch (error) {
1596
+ return mcpErrorResult(error);
1597
+ }
1598
+ };
1599
+ }
1600
+ return _serverTool.apply(null, args);
1601
+ };
1492
1602
  function jsonResponse(data) {
1493
1603
  return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
1494
1604
  }
@@ -1582,9 +1692,18 @@ server.tool(
1582
1692
  {
1583
1693
  budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1584
1694
  sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
1585
- type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type")
1695
+ type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type"),
1696
+ lastKnowledgeOfServer: z.number().optional().describe("Delta sync: only return changes since this server knowledge value. Response includes server_knowledge for next call."),
1697
+ limit: z.number().optional().describe("Maximum number of transactions to return"),
1698
+ fields: z.string().optional().describe("Comma-separated list of fields to include (e.g., id,date,amount,memo)")
1586
1699
  },
1587
- async ({ budgetId, sinceDate, type }) => currencyResponse(await client.getTransactions({ budgetId, sinceDate, type }))
1700
+ async ({ budgetId, sinceDate, type, lastKnowledgeOfServer, limit, fields }) => {
1701
+ const result = await client.getTransactions({ budgetId, sinceDate, type, lastKnowledgeOfServer });
1702
+ let transactions = result?.transactions || [];
1703
+ if (limit && limit > 0) transactions = transactions.slice(0, limit);
1704
+ const selected = fields ? applyFieldSelection(transactions, fields) : transactions;
1705
+ return currencyResponse({ transactions: selected, server_knowledge: result?.server_knowledge });
1706
+ }
1588
1707
  );
1589
1708
  server.tool(
1590
1709
  "get_transaction",
@@ -1703,6 +1822,64 @@ server.tool(
1703
1822
  );
1704
1823
  }
1705
1824
  );
1825
+ server.tool(
1826
+ "summarize_transactions",
1827
+ "Get aggregate summary of transactions by payee, category, and status",
1828
+ {
1829
+ budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1830
+ sinceDate: z.string().optional().describe("Only return transactions on or after this date (YYYY-MM-DD)"),
1831
+ untilDate: z.string().optional().describe("Only return transactions on or before this date (YYYY-MM-DD)"),
1832
+ type: z.enum(["uncategorized", "unapproved"]).optional().describe("Filter by transaction type"),
1833
+ approved: z.enum(["true", "false"]).optional().describe("Filter by approval status"),
1834
+ status: z.string().optional().describe("Filter by cleared status: cleared, uncleared, reconciled (comma-separated)"),
1835
+ minAmount: z.number().optional().describe("Minimum amount in dollars"),
1836
+ maxAmount: z.number().optional().describe("Maximum amount in dollars"),
1837
+ top: z.number().optional().describe("Limit payee/category breakdowns to top N entries")
1838
+ },
1839
+ async ({ budgetId, sinceDate, untilDate, type, approved, status, minAmount, maxAmount, top }) => {
1840
+ const result = await client.getTransactions({ budgetId, sinceDate, type });
1841
+ const transactions = result?.transactions || [];
1842
+ const filtered = applyTransactionFilters(transactions, {
1843
+ until: untilDate,
1844
+ approved,
1845
+ status,
1846
+ minAmount,
1847
+ maxAmount
1848
+ });
1849
+ const summary = summarizeTransactions(
1850
+ filtered,
1851
+ top ? { top } : void 0
1852
+ );
1853
+ return currencyResponse(summary);
1854
+ }
1855
+ );
1856
+ server.tool(
1857
+ "find_transfer_candidates",
1858
+ "Find candidate transfer matches for a transaction across accounts",
1859
+ {
1860
+ transactionId: z.string().describe("Transaction ID to find transfers for"),
1861
+ budgetId: z.string().optional().describe("Budget ID (uses default if not specified)"),
1862
+ maxDays: z.number().optional().describe("Maximum date difference in days (default: 3)"),
1863
+ sinceDate: z.string().optional().describe("Search transactions since date (defaults to source date minus maxDays)")
1864
+ },
1865
+ async ({ transactionId, budgetId, maxDays: maxDaysParam, sinceDate }) => {
1866
+ const days = maxDaysParam ?? 3;
1867
+ const source = await client.getTransaction(transactionId, budgetId);
1868
+ if (!sinceDate) {
1869
+ const d = new Date(source.date);
1870
+ d.setDate(d.getDate() - days);
1871
+ sinceDate = d.toISOString().split("T")[0];
1872
+ }
1873
+ const result = await client.getTransactions({ budgetId, sinceDate });
1874
+ const allTransactions = result?.transactions || [];
1875
+ const candidates = findTransferCandidates(
1876
+ source,
1877
+ allTransactions,
1878
+ { maxDays: days }
1879
+ );
1880
+ return currencyResponse({ source, candidates });
1881
+ }
1882
+ );
1706
1883
  server.tool(
1707
1884
  "list_transactions_by_account",
1708
1885
  "List transactions for a specific account",
@@ -1867,7 +2044,7 @@ function createMcpCommand() {
1867
2044
 
1868
2045
  // src/cli.ts
1869
2046
  var program = new Command12();
1870
- program.name("ynab").description("A command-line interface for You Need a Budget (YNAB)").version("2.7.0").option("-c, --compact", "Minified JSON output (single line)").hook("preAction", (thisCommand) => {
2047
+ program.name("ynab").description("A command-line interface for You Need a Budget (YNAB)").version("2.8.1").option("-c, --compact", "Minified JSON output (single line)").hook("preAction", (thisCommand) => {
1871
2048
  const options = thisCommand.opts();
1872
2049
  setOutputOptions({
1873
2050
  compact: options.compact