@dizzlkheinz/ynab-mcpb 0.18.3 → 0.18.4

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 (33) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/bundle/index.cjs +40 -40
  3. package/dist/tools/reconcileAdapter.js +3 -0
  4. package/dist/tools/reconciliation/analyzer.js +72 -7
  5. package/dist/tools/reconciliation/reportFormatter.js +26 -2
  6. package/dist/tools/reconciliation/types.d.ts +3 -0
  7. package/dist/tools/transactionSchemas.d.ts +309 -0
  8. package/dist/tools/transactionSchemas.js +215 -0
  9. package/dist/tools/transactionTools.d.ts +3 -281
  10. package/dist/tools/transactionTools.js +4 -559
  11. package/dist/tools/transactionUtils.d.ts +31 -0
  12. package/dist/tools/transactionUtils.js +349 -0
  13. package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
  14. package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
  15. package/package.json +4 -2
  16. package/scripts/run-all-tests.js +196 -0
  17. package/src/tools/__tests__/transactionSchemas.test.ts +1188 -0
  18. package/src/tools/__tests__/transactionUtils.test.ts +989 -0
  19. package/src/tools/reconcileAdapter.ts +6 -0
  20. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +22 -8
  21. package/src/tools/reconciliation/__tests__/adapter.test.ts +3 -0
  22. package/src/tools/reconciliation/__tests__/analyzer.test.ts +65 -0
  23. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +3 -0
  24. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +4 -1
  25. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +3 -0
  26. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +5 -1
  27. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +22 -8
  28. package/src/tools/reconciliation/analyzer.ts +127 -11
  29. package/src/tools/reconciliation/reportFormatter.ts +39 -2
  30. package/src/tools/reconciliation/types.ts +6 -0
  31. package/src/tools/transactionSchemas.ts +453 -0
  32. package/src/tools/transactionTools.ts +102 -823
  33. package/src/tools/transactionUtils.ts +536 -0
@@ -2,9 +2,7 @@ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
2
2
  import * as ynab from 'ynab';
3
3
  import { SaveTransaction } from 'ynab/dist/models/SaveTransaction.js';
4
4
  import { SaveSubTransaction } from 'ynab/dist/models/SaveSubTransaction.js';
5
- import type { SaveTransactionsResponseData } from 'ynab/dist/models/SaveTransactionsResponseData.js';
6
5
  import { z } from 'zod/v4';
7
- import { createHash } from 'crypto';
8
6
  import { ValidationError, withToolErrorHandling } from '../types/index.js';
9
7
  import type { ToolFactory } from '../types/toolRegistration.js';
10
8
  import { createAdapters, createBudgetResolver } from './adapters.js';
@@ -19,707 +17,53 @@ import type { ServerKnowledgeStore } from '../server/serverKnowledgeStore.js';
19
17
  import { resolveDeltaFetcherArgs, resolveDeltaWriteArgs } from './deltaSupport.js';
20
18
  import { handleExportTransactions, ExportTransactionsSchema } from './exportTransactions.js';
21
19
 
22
- /**
23
- * Utility function to ensure transaction is not null/undefined
24
- */
25
- function ensureTransaction<T>(transaction: T | undefined, errorMessage: string): T {
26
- if (!transaction) {
27
- throw new Error(errorMessage);
28
- }
29
- return transaction;
30
- }
31
-
32
- const toMonthKey = (date: string): string => `${date.slice(0, 7)}-01`;
33
-
34
- interface CategorySource {
35
- category_id?: string | null;
36
- subtransactions?: { category_id?: string | null }[] | null | undefined;
37
- }
38
-
39
- function appendCategoryIds(source: CategorySource | undefined, target: Set<string>): void {
40
- if (!source) {
41
- return;
42
- }
43
- if (source.category_id) {
44
- target.add(source.category_id);
45
- }
46
- if (Array.isArray(source.subtransactions)) {
47
- for (const sub of source.subtransactions) {
48
- if (sub?.category_id) {
49
- target.add(sub.category_id);
50
- }
51
- }
52
- }
53
- }
54
-
55
- function collectCategoryIdsFromSources(...sources: (CategorySource | undefined)[]): Set<string> {
56
- const result = new Set<string>();
57
- for (const source of sources) {
58
- appendCategoryIds(source, result);
59
- }
60
- return result;
61
- }
62
-
63
- function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
64
- if (a.size !== b.size) {
65
- return false;
66
- }
67
- for (const value of a) {
68
- if (!b.has(value)) {
69
- return false;
70
- }
71
- }
72
- return true;
73
- }
74
-
75
- interface TransactionCacheInvalidationOptions {
76
- affectedCategoryIds?: Set<string>;
77
- invalidateAllCategories?: boolean;
78
- accountTotalsChanged?: boolean;
79
- invalidateMonths?: boolean;
80
- }
81
-
82
- function invalidateTransactionCaches(
83
- deltaCache: DeltaCache,
84
- knowledgeStore: ServerKnowledgeStore,
85
- budgetId: string,
86
- serverKnowledge: number | undefined,
87
- affectedAccountIds: Set<string>,
88
- affectedMonths: Set<string>,
89
- options: TransactionCacheInvalidationOptions = {},
90
- ): void {
91
- deltaCache.invalidate(budgetId, 'transactions');
92
- cacheManager.delete(CacheManager.generateKey('transactions', 'list', budgetId));
93
-
94
- for (const accountId of affectedAccountIds) {
95
- const accountPrefix = CacheManager.generateKey('transactions', 'account', budgetId, accountId);
96
- cacheManager.deleteByPrefix(accountPrefix);
97
- }
98
-
99
- const invalidateAccountsList = options.accountTotalsChanged ?? true;
100
- if (invalidateAccountsList) {
101
- cacheManager.delete(CacheManager.generateKey('accounts', 'list', budgetId));
102
- }
103
- for (const accountId of affectedAccountIds) {
104
- cacheManager.delete(CacheManager.generateKey('account', 'get', budgetId, accountId));
105
- }
106
-
107
- const affectedCategoryIds = options.affectedCategoryIds ?? new Set<string>();
108
- const shouldInvalidateCategories =
109
- options.invalidateAllCategories || affectedCategoryIds.size > 0;
110
- if (shouldInvalidateCategories) {
111
- cacheManager.delete(CacheManager.generateKey('categories', 'list', budgetId));
112
- for (const categoryId of affectedCategoryIds) {
113
- cacheManager.delete(CacheManager.generateKey('category', 'get', budgetId, categoryId));
114
- }
115
- }
116
-
117
- const shouldInvalidateMonths = options.invalidateMonths ?? affectedMonths.size > 0;
118
- if (shouldInvalidateMonths) {
119
- cacheManager.delete(CacheManager.generateKey('months', 'list', budgetId));
120
- deltaCache.invalidate(budgetId, 'months');
121
- for (const month of affectedMonths) {
122
- cacheManager.delete(CacheManager.generateKey('month', 'get', budgetId, month));
123
- }
124
- }
125
-
126
- if (serverKnowledge !== undefined) {
127
- const transactionCacheKey = CacheManager.generateKey('transactions', 'list', budgetId);
128
- knowledgeStore.update(transactionCacheKey, serverKnowledge);
129
- if (invalidateAccountsList) {
130
- const accountsCacheKey = CacheManager.generateKey('accounts', 'list', budgetId);
131
- knowledgeStore.update(accountsCacheKey, serverKnowledge);
132
- }
133
- if (shouldInvalidateMonths && affectedMonths.size > 0) {
134
- const monthsCacheKey = CacheManager.generateKey('months', 'list', budgetId);
135
- knowledgeStore.update(monthsCacheKey, serverKnowledge);
136
- }
137
- }
138
- }
139
-
140
- /**
141
- * Schema for ynab:list_transactions tool parameters
142
- */
143
- export const ListTransactionsSchema = z
144
- .object({
145
- budget_id: z.string().min(1, 'Budget ID is required'),
146
- account_id: z.string().optional(),
147
- category_id: z.string().optional(),
148
- since_date: z
149
- .string()
150
- .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
151
- .optional(),
152
- type: z.enum(['uncategorized', 'unapproved']).optional(),
153
- })
154
- .strict();
155
-
156
- export type ListTransactionsParams = z.infer<typeof ListTransactionsSchema>;
157
-
158
- /**
159
- * Schema for ynab:get_transaction tool parameters
160
- */
161
- export const GetTransactionSchema = z
162
- .object({
163
- budget_id: z.string().min(1, 'Budget ID is required'),
164
- transaction_id: z.string().min(1, 'Transaction ID is required'),
165
- })
166
- .strict();
167
-
168
- export type GetTransactionParams = z.infer<typeof GetTransactionSchema>;
169
-
170
- /**
171
- * Schema for ynab:create_transaction tool parameters
172
- */
173
- export const CreateTransactionSchema = z
174
- .object({
175
- budget_id: z.string().min(1, 'Budget ID is required'),
176
- account_id: z.string().min(1, 'Account ID is required'),
177
- amount: z.number().int('Amount must be an integer in milliunits'),
178
- date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)'),
179
- payee_name: z.string().optional(),
180
- payee_id: z.string().optional(),
181
- category_id: z.string().optional(),
182
- memo: z.string().optional(),
183
- cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
184
- approved: z.boolean().optional(),
185
- flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional(),
186
- import_id: z.string().min(1, 'Import ID cannot be empty').optional(),
187
- dry_run: z.boolean().optional(),
188
- subtransactions: z
189
- .array(
190
- z
191
- .object({
192
- amount: z.number().int('Subtransaction amount must be an integer in milliunits'),
193
- payee_name: z.string().optional(),
194
- payee_id: z.string().optional(),
195
- category_id: z.string().optional(),
196
- memo: z.string().optional(),
197
- })
198
- .strict(),
199
- )
200
- .min(1, 'At least one subtransaction is required when provided')
201
- .optional(),
202
- })
203
- .strict()
204
- .superRefine((data, ctx) => {
205
- if (data.subtransactions && data.subtransactions.length > 0) {
206
- const total = data.subtransactions.reduce((sum, sub) => sum + sub.amount, 0);
207
- if (total !== data.amount) {
208
- ctx.addIssue({
209
- code: z.ZodIssueCode.custom,
210
- message: 'Amount must equal the sum of subtransaction amounts',
211
- path: ['amount'],
212
- });
213
- }
214
- }
215
- });
216
-
217
- export type CreateTransactionParams = z.infer<typeof CreateTransactionSchema>;
218
-
219
- const BulkTransactionInputSchemaBase = CreateTransactionSchema.pick({
220
- account_id: true,
221
- amount: true,
222
- date: true,
223
- payee_name: true,
224
- payee_id: true,
225
- category_id: true,
226
- memo: true,
227
- cleared: true,
228
- approved: true,
229
- flag_color: true,
230
- import_id: true,
231
- });
232
-
233
- type BulkTransactionInput = Omit<
20
+ // Import schemas and types from transactionSchemas.ts
21
+ import {
22
+ ListTransactionsSchema,
23
+ ListTransactionsParams,
24
+ GetTransactionSchema,
25
+ GetTransactionParams,
26
+ CreateTransactionSchema,
234
27
  CreateTransactionParams,
235
- 'budget_id' | 'dry_run' | 'subtransactions'
236
- >;
237
-
238
- // Schema for bulk transaction creation - subtransactions are not supported
239
- // The .strict() modifier automatically rejects any fields not in the schema
240
- const BulkTransactionInputSchema = BulkTransactionInputSchemaBase.strict();
241
-
242
- export const CreateTransactionsSchema = z
243
- .object({
244
- budget_id: z.string().min(1, 'Budget ID is required'),
245
- transactions: z
246
- .array(BulkTransactionInputSchema)
247
- .min(1, 'At least one transaction is required')
248
- .max(100, 'A maximum of 100 transactions may be created at once'),
249
- dry_run: z.boolean().optional(),
250
- })
251
- .strict();
252
-
253
- export type CreateTransactionsParams = z.infer<typeof CreateTransactionsSchema>;
254
-
255
- export interface BulkTransactionResult {
256
- request_index: number;
257
- status: 'created' | 'duplicate' | 'failed';
258
- transaction_id?: string | undefined;
259
- correlation_key: string;
260
- error_code?: string | undefined;
261
- error?: string | undefined;
262
- }
263
-
264
- export interface BulkCreateResponse {
265
- success: boolean;
266
- server_knowledge?: number;
267
- summary: {
268
- total_requested: number;
269
- created: number;
270
- duplicates: number;
271
- failed: number;
272
- };
273
- results: BulkTransactionResult[];
274
- transactions?: ynab.TransactionDetail[];
275
- duplicate_import_ids?: string[];
276
- message?: string;
277
- mode?: 'full' | 'summary' | 'ids_only';
278
- }
279
-
280
- const FULL_RESPONSE_THRESHOLD = 64 * 1024;
281
- const SUMMARY_RESPONSE_THRESHOLD = 96 * 1024;
282
- const MAX_RESPONSE_BYTES = 100 * 1024;
283
-
284
- export function generateCorrelationKey(transaction: {
285
- account_id?: string;
286
- date?: string;
287
- amount?: number;
288
- payee_id?: string | null;
289
- payee_name?: string | null;
290
- category_id?: string | null;
291
- memo?: string | null;
292
- cleared?: ynab.TransactionClearedStatus;
293
- approved?: boolean;
294
- flag_color?: ynab.TransactionFlagColor | null;
295
- import_id?: string | null;
296
- }): string {
297
- if (transaction.import_id) {
298
- return transaction.import_id;
299
- }
300
-
301
- const segments = [
302
- `account:${transaction.account_id ?? ''}`,
303
- `date:${transaction.date ?? ''}`,
304
- `amount:${transaction.amount ?? 0}`,
305
- `payee:${transaction.payee_id ?? transaction.payee_name ?? ''}`,
306
- `category:${transaction.category_id ?? ''}`,
307
- `memo:${transaction.memo ?? ''}`,
308
- `cleared:${transaction.cleared ?? ''}`,
309
- `approved:${transaction.approved ?? false}`,
310
- `flag:${transaction.flag_color ?? ''}`,
311
- ];
312
-
313
- const normalized = segments.join('|');
314
- const hash = createHash('sha256').update(normalized).digest('hex').slice(0, 16);
315
- return `hash:${hash}`;
316
- }
317
-
318
- type CorrelationPayload = Parameters<typeof generateCorrelationKey>[0];
319
-
320
- interface CorrelationPayloadInput {
321
- account_id?: string | undefined;
322
- date?: string | undefined;
323
- amount?: number | undefined;
324
- payee_id?: string | null | undefined;
325
- payee_name?: string | null | undefined;
326
- category_id?: string | null | undefined;
327
- memo?: string | null | undefined;
328
- cleared?: ynab.TransactionClearedStatus | undefined;
329
- approved?: boolean | undefined;
330
- flag_color?: ynab.TransactionFlagColor | null | undefined;
331
- import_id?: string | null | undefined;
332
- }
333
-
334
- export function toCorrelationPayload(transaction: CorrelationPayloadInput): CorrelationPayload {
335
- const payload: CorrelationPayload = {};
336
- if (transaction.account_id !== undefined) {
337
- payload.account_id = transaction.account_id;
338
- }
339
- if (transaction.date !== undefined) {
340
- payload.date = transaction.date;
341
- }
342
- if (transaction.amount !== undefined) {
343
- payload.amount = transaction.amount;
344
- }
345
- if (transaction.cleared !== undefined) {
346
- payload.cleared = transaction.cleared;
347
- }
348
- if (transaction.approved !== undefined) {
349
- payload.approved = transaction.approved;
350
- }
351
- if (transaction.flag_color !== undefined) {
352
- payload.flag_color = transaction.flag_color;
353
- }
354
- payload.payee_id = transaction.payee_id ?? null;
355
- payload.payee_name = transaction.payee_name ?? null;
356
- payload.category_id = transaction.category_id ?? null;
357
- payload.memo = transaction.memo ?? null;
358
- payload.import_id = transaction.import_id ?? null;
359
- return payload;
360
- }
361
-
362
- export function correlateResults(
363
- requests: BulkTransactionInput[],
364
- responseData: SaveTransactionsResponseData,
365
- duplicateImportIds: Set<string>,
366
- ): BulkTransactionResult[] {
367
- const createdByImportId = new Map<string, string[]>();
368
- const createdByHash = new Map<string, string[]>();
369
- const responseTransactions = responseData.transactions ?? [];
370
-
371
- const register = (map: Map<string, string[]>, key: string, transactionId: string): void => {
372
- const existing = map.get(key);
373
- if (existing) {
374
- existing.push(transactionId);
375
- return;
376
- }
377
- map.set(key, [transactionId]);
378
- };
379
-
380
- for (const transaction of responseTransactions) {
381
- if (!transaction.id) {
382
- continue;
383
- }
384
- const key = generateCorrelationKey(transaction);
385
- if (key.startsWith('hash:')) {
386
- register(createdByHash, key, transaction.id);
387
- } else {
388
- register(createdByImportId, key, transaction.id);
389
- }
390
- }
391
-
392
- const popId = (map: Map<string, string[]>, key: string): string | undefined => {
393
- const bucket = map.get(key);
394
- if (!bucket || bucket.length === 0) {
395
- return undefined;
396
- }
397
- const [transactionId] = bucket.splice(0, 1);
398
- if (bucket.length === 0) {
399
- map.delete(key);
400
- }
401
- return transactionId;
402
- };
403
-
404
- const correlatedResults: BulkTransactionResult[] = [];
405
-
406
- for (const [index, transaction] of requests.entries()) {
407
- const normalizedRequest = toCorrelationPayload(transaction);
408
- const correlationKey = generateCorrelationKey(normalizedRequest);
409
-
410
- if (transaction.import_id && duplicateImportIds.has(transaction.import_id)) {
411
- correlatedResults.push({
412
- request_index: index,
413
- status: 'duplicate',
414
- correlation_key: correlationKey,
415
- });
416
- continue;
417
- }
418
-
419
- let transactionId: string | undefined;
420
- if (correlationKey.startsWith('hash:')) {
421
- transactionId = popId(createdByHash, correlationKey);
422
- } else {
423
- transactionId = popId(createdByImportId, correlationKey);
424
- }
425
-
426
- if (!transactionId && !correlationKey.startsWith('hash:')) {
427
- // Attempt hash-based fallback if import_id was not matched.
428
- const hashKey = generateCorrelationKey(
429
- toCorrelationPayload({ ...transaction, import_id: undefined }),
430
- );
431
- transactionId = popId(createdByHash, hashKey);
432
- }
433
-
434
- if (transactionId) {
435
- const successResult: BulkTransactionResult = {
436
- request_index: index,
437
- status: 'created',
438
- correlation_key: correlationKey,
439
- };
440
- successResult.transaction_id = transactionId;
441
- correlatedResults.push(successResult);
442
- continue;
443
- }
444
-
445
- globalRequestLogger.logError(
446
- 'ynab:create_transactions',
447
- 'correlate_results',
448
- {
449
- request_index: index,
450
- correlation_key: correlationKey,
451
- request: {
452
- account_id: transaction.account_id,
453
- date: transaction.date,
454
- amount: transaction.amount,
455
- import_id: transaction.import_id,
456
- },
457
- },
458
- 'correlation_failed',
459
- );
460
-
461
- correlatedResults.push({
462
- request_index: index,
463
- status: 'failed',
464
- correlation_key: correlationKey,
465
- error_code: 'correlation_failed',
466
- error: 'Unable to correlate request transaction with YNAB response',
467
- });
468
- }
469
-
470
- return correlatedResults;
471
- }
472
-
473
- function estimatePayloadSize(payload: BulkCreateResponse | BulkUpdateResponse): number {
474
- return Buffer.byteLength(JSON.stringify(payload), 'utf8');
475
- }
476
-
477
- function finalizeResponse(response: BulkCreateResponse): BulkCreateResponse {
478
- const appendMessage = (message: string | undefined, addition: string): string => {
479
- if (!message) {
480
- return addition;
481
- }
482
- if (message.includes(addition)) {
483
- return message;
484
- }
485
- return `${message} ${addition}`;
486
- };
487
-
488
- const fullSize = estimatePayloadSize({ ...response, mode: 'full' });
489
- if (fullSize <= FULL_RESPONSE_THRESHOLD) {
490
- return { ...response, mode: 'full' };
491
- }
492
-
493
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
494
- const { transactions, ...summaryResponse } = response;
495
- const summaryPayload: BulkCreateResponse = {
496
- ...summaryResponse,
497
- message: appendMessage(
498
- response.message,
499
- 'Response downgraded to summary to stay under size limits.',
500
- ),
501
- mode: 'summary',
502
- };
503
-
504
- if (estimatePayloadSize(summaryPayload) <= SUMMARY_RESPONSE_THRESHOLD) {
505
- return summaryPayload;
506
- }
507
-
508
- const idsOnlyPayload: BulkCreateResponse = {
509
- ...summaryPayload,
510
- results: summaryResponse.results.map((result) => ({
511
- request_index: result.request_index,
512
- status: result.status,
513
- transaction_id: result.transaction_id,
514
- correlation_key: result.correlation_key,
515
- error: result.error,
516
- })),
517
- message: appendMessage(
518
- summaryResponse.message,
519
- 'Response downgraded to ids_only to meet 100KB limit.',
520
- ),
521
- mode: 'ids_only',
522
- };
523
-
524
- if (estimatePayloadSize(idsOnlyPayload) <= MAX_RESPONSE_BYTES) {
525
- return idsOnlyPayload;
526
- }
527
-
528
- throw new ValidationError(
529
- 'RESPONSE_TOO_LARGE: Unable to format bulk create response within 100KB limit',
530
- `Batch size: ${response.summary.total_requested} transactions`,
531
- ['Reduce the batch size and retry', 'Consider splitting into multiple smaller batches'],
532
- );
533
- }
534
-
535
- const ReceiptSplitItemSchema = z
536
- .object({
537
- name: z.string().min(1, 'Item name is required'),
538
- amount: z.number().finite('Item amount must be a finite number'),
539
- quantity: z
540
- .number()
541
- .finite('Quantity must be a finite number')
542
- .positive('Quantity must be greater than zero')
543
- .optional(),
544
- memo: z.string().optional(),
545
- })
546
- .strict();
547
-
548
- const ReceiptSplitCategorySchema = z
549
- .object({
550
- category_id: z.string().min(1, 'Category ID is required'),
551
- category_name: z.string().optional(),
552
- items: z.array(ReceiptSplitItemSchema).min(1, 'Each category must include at least one item'),
553
- })
554
- .strict();
555
-
556
- export const CreateReceiptSplitTransactionSchema = z
557
- .object({
558
- budget_id: z.string().min(1, 'Budget ID is required'),
559
- account_id: z.string().min(1, 'Account ID is required'),
560
- payee_name: z.string().min(1, 'Payee name is required'),
561
- date: z
562
- .string()
563
- .regex(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
564
- .optional(),
565
- memo: z.string().optional(),
566
- receipt_subtotal: z
567
- .number()
568
- .finite('Receipt subtotal must be a finite number')
569
- .refine((value) => value >= 0, 'Receipt subtotal must be zero or greater')
570
- .optional(),
571
- receipt_tax: z.number().finite('Receipt tax must be a finite number'),
572
- receipt_total: z
573
- .number()
574
- .finite('Receipt total must be a finite number')
575
- .refine((value) => value > 0, 'Receipt total must be greater than zero'),
576
- categories: z
577
- .array(ReceiptSplitCategorySchema)
578
- .min(1, 'At least one categorized group is required to create a split transaction'),
579
- cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
580
- approved: z.boolean().optional(),
581
- flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional(),
582
- dry_run: z.boolean().optional(),
583
- })
584
- .strict()
585
- .superRefine((data, ctx) => {
586
- const itemsSubtotal = data.categories
587
- .flatMap((category) => category.items)
588
- .reduce((sum, item) => sum + item.amount, 0);
589
-
590
- if (data.receipt_subtotal !== undefined) {
591
- const delta = Math.abs(data.receipt_subtotal - itemsSubtotal);
592
- if (delta > 0.01) {
593
- ctx.addIssue({
594
- code: z.ZodIssueCode.custom,
595
- message: `Receipt subtotal (${data.receipt_subtotal.toFixed(2)}) does not match categorized items total (${itemsSubtotal.toFixed(2)})`,
596
- path: ['receipt_subtotal'],
597
- });
598
- }
599
- }
600
-
601
- const expectedTotal = itemsSubtotal + data.receipt_tax;
602
- const deltaTotal = Math.abs(expectedTotal - data.receipt_total);
603
- if (deltaTotal > 0.01) {
604
- ctx.addIssue({
605
- code: z.ZodIssueCode.custom,
606
- message: `Receipt total (${data.receipt_total.toFixed(2)}) does not match subtotal plus tax (${expectedTotal.toFixed(2)})`,
607
- path: ['receipt_total'],
608
- });
609
- }
610
- });
611
-
612
- export type CreateReceiptSplitTransactionParams = z.infer<
613
- typeof CreateReceiptSplitTransactionSchema
614
- >;
615
-
616
- /**
617
- * Schema for ynab:update_transaction tool parameters
618
- */
619
- export const UpdateTransactionSchema = z
620
- .object({
621
- budget_id: z.string().min(1, 'Budget ID is required'),
622
- transaction_id: z.string().min(1, 'Transaction ID is required'),
623
- account_id: z.string().optional(),
624
- amount: z.number().int('Amount must be an integer in milliunits').optional(),
625
- date: z
626
- .string()
627
- .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
628
- .optional(),
629
- payee_name: z.string().optional(),
630
- payee_id: z.string().optional(),
631
- category_id: z.string().optional(),
632
- memo: z.string().optional(),
633
- cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
634
- approved: z.boolean().optional(),
635
- flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional(),
636
- dry_run: z.boolean().optional(),
637
- })
638
- .strict();
639
-
640
- export type UpdateTransactionParams = z.infer<typeof UpdateTransactionSchema>;
641
-
642
- /**
643
- * Schema for bulk transaction updates - each item in the array
644
- * Note: account_id is intentionally excluded as account moves are not supported in bulk updates
645
- */
646
- const BulkUpdateTransactionInputSchema = z
647
- .object({
648
- id: z.string().min(1, 'Transaction ID is required'),
649
- amount: z.number().int('Amount must be an integer in milliunits').optional(),
650
- date: z
651
- .string()
652
- .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
653
- .optional(),
654
- payee_name: z.string().optional(),
655
- payee_id: z.string().optional(),
656
- category_id: z.string().optional(),
657
- memo: z.string().optional(),
658
- cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
659
- approved: z.boolean().optional(),
660
- flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional(),
661
- // Metadata fields for cache invalidation
662
- original_account_id: z.string().optional(),
663
- original_date: z
664
- .string()
665
- .regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
666
- .optional(),
667
- })
668
- .strict();
669
-
670
- export type BulkUpdateTransactionInput = z.infer<typeof BulkUpdateTransactionInputSchema>;
671
-
672
- /**
673
- * Schema for ynab:update_transactions tool parameters
674
- */
675
- export const UpdateTransactionsSchema = z
676
- .object({
677
- budget_id: z.string().min(1, 'Budget ID is required'),
678
- transactions: z
679
- .array(BulkUpdateTransactionInputSchema)
680
- .min(1, 'At least one transaction is required')
681
- .max(100, 'A maximum of 100 transactions may be updated at once'),
682
- dry_run: z.boolean().optional(),
683
- })
684
- .strict();
685
-
686
- export type UpdateTransactionsParams = z.infer<typeof UpdateTransactionsSchema>;
687
-
688
- export interface BulkUpdateResult {
689
- request_index: number;
690
- status: 'updated' | 'failed';
691
- transaction_id: string;
692
- correlation_key: string;
693
- error_code?: string;
694
- error?: string;
695
- }
696
-
697
- export interface BulkUpdateResponse {
698
- success: boolean;
699
- server_knowledge?: number;
700
- summary: {
701
- total_requested: number;
702
- updated: number;
703
- failed: number;
704
- };
705
- results: BulkUpdateResult[];
706
- transactions?: ynab.TransactionDetail[];
707
- message?: string;
708
- mode?: 'full' | 'summary' | 'ids_only';
709
- }
28
+ CreateTransactionsSchema,
29
+ CreateTransactionsParams,
30
+ CreateReceiptSplitTransactionSchema,
31
+ CreateReceiptSplitTransactionParams,
32
+ UpdateTransactionSchema,
33
+ UpdateTransactionParams,
34
+ UpdateTransactionsSchema,
35
+ UpdateTransactionsParams,
36
+ BulkUpdateTransactionInput,
37
+ DeleteTransactionSchema,
38
+ DeleteTransactionParams,
39
+ BulkCreateResponse,
40
+ BulkUpdateResult,
41
+ BulkUpdateResponse,
42
+ ReceiptCategoryCalculation,
43
+ SubtransactionInput,
44
+ } from './transactionSchemas.js';
45
+
46
+ // Import utility functions from transactionUtils.ts
47
+ import {
48
+ ensureTransaction,
49
+ appendCategoryIds,
50
+ collectCategoryIdsFromSources,
51
+ setsEqual,
52
+ invalidateTransactionCaches,
53
+ correlateResults,
54
+ finalizeResponse,
55
+ finalizeBulkUpdateResponse,
56
+ handleTransactionError,
57
+ toMonthKey,
58
+ } from './transactionUtils.js';
710
59
 
711
60
  /**
712
- * Schema for ynab:delete_transaction tool parameters
61
+ * Transaction Tool Handlers
62
+ *
63
+ * All schemas, types, and utility functions have been extracted to:
64
+ * - transactionSchemas.ts - Zod schemas and TypeScript types
65
+ * - transactionUtils.ts - Utility functions and helpers
713
66
  */
714
- export const DeleteTransactionSchema = z
715
- .object({
716
- budget_id: z.string().min(1, 'Budget ID is required'),
717
- transaction_id: z.string().min(1, 'Transaction ID is required'),
718
- dry_run: z.boolean().optional(),
719
- })
720
- .strict();
721
-
722
- export type DeleteTransactionParams = z.infer<typeof DeleteTransactionSchema>;
723
67
 
724
68
  /**
725
69
  * Handles the ynab:list_transactions tool call
@@ -1091,27 +435,6 @@ export async function handleCreateTransaction(
1091
435
  }
1092
436
  }
1093
437
 
1094
- interface ReceiptCategoryCalculation {
1095
- category_id: string;
1096
- category_name: string | undefined;
1097
- subtotal_milliunits: number;
1098
- tax_milliunits: number;
1099
- items: {
1100
- name: string;
1101
- amount_milliunits: number;
1102
- quantity: number | undefined;
1103
- memo: string | undefined;
1104
- }[];
1105
- }
1106
-
1107
- interface SubtransactionInput {
1108
- amount: number;
1109
- payee_name?: string;
1110
- payee_id?: string;
1111
- category_id?: string;
1112
- memo?: string;
1113
- }
1114
-
1115
438
  /**
1116
439
  * Constants for smart collapse logic
1117
440
  */
@@ -2331,75 +1654,6 @@ async function resolveMetadata(
2331
1654
  return { metadata, unresolvedIds: Array.from(metadataAwaitingResolution), previewDetails };
2332
1655
  }
2333
1656
 
2334
- /**
2335
- * Finalizes bulk update response based on size constraints
2336
- */
2337
- function finalizeBulkUpdateResponse(response: BulkUpdateResponse): BulkUpdateResponse {
2338
- const appendMessage = (message: string | undefined, addition: string): string => {
2339
- if (!message) {
2340
- return addition;
2341
- }
2342
- if (message.includes(addition)) {
2343
- return message;
2344
- }
2345
- return `${message} ${addition}`;
2346
- };
2347
-
2348
- const fullSize = estimatePayloadSize(response);
2349
- if (fullSize <= FULL_RESPONSE_THRESHOLD) {
2350
- return { ...response, mode: 'full' };
2351
- }
2352
-
2353
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
2354
- const { transactions, ...summaryResponse } = response;
2355
- const summaryPayload: BulkUpdateResponse = {
2356
- ...summaryResponse,
2357
- message: appendMessage(
2358
- response.message,
2359
- 'Response downgraded to summary to stay under size limits.',
2360
- ),
2361
- mode: 'summary',
2362
- };
2363
-
2364
- if (estimatePayloadSize(summaryPayload) <= SUMMARY_RESPONSE_THRESHOLD) {
2365
- return summaryPayload;
2366
- }
2367
-
2368
- const idsOnlyPayload: BulkUpdateResponse = {
2369
- ...summaryPayload,
2370
- results: summaryResponse.results.map((result) => {
2371
- const simplified: BulkUpdateResult = {
2372
- request_index: result.request_index,
2373
- status: result.status,
2374
- transaction_id: result.transaction_id,
2375
- correlation_key: result.correlation_key,
2376
- };
2377
- if (result.error) {
2378
- simplified.error = result.error;
2379
- }
2380
- if (result.error_code) {
2381
- simplified.error_code = result.error_code;
2382
- }
2383
- return simplified;
2384
- }),
2385
- message: appendMessage(
2386
- summaryResponse.message,
2387
- 'Response downgraded to ids_only to meet 100KB limit.',
2388
- ),
2389
- mode: 'ids_only',
2390
- };
2391
-
2392
- if (estimatePayloadSize(idsOnlyPayload) <= MAX_RESPONSE_BYTES) {
2393
- return idsOnlyPayload;
2394
- }
2395
-
2396
- throw new ValidationError(
2397
- 'RESPONSE_TOO_LARGE: Unable to format bulk update response within 100KB limit',
2398
- `Batch size: ${response.summary.total_requested} transactions`,
2399
- ['Reduce the batch size and retry', 'Consider splitting into multiple smaller batches'],
2400
- );
2401
- }
2402
-
2403
1657
  /**
2404
1658
  * Handles the ynab:update_transactions tool call
2405
1659
  * Updates multiple transactions in a single batch operation
@@ -2824,40 +2078,6 @@ export async function handleUpdateTransactions(
2824
2078
  )) as CallToolResult;
2825
2079
  }
2826
2080
 
2827
- /**
2828
- * Handles errors from transaction-related API calls
2829
- */
2830
- function handleTransactionError(error: unknown, defaultMessage: string): CallToolResult {
2831
- let errorMessage = defaultMessage;
2832
-
2833
- if (error instanceof Error) {
2834
- if (error.message.includes('401') || error.message.includes('Unauthorized')) {
2835
- errorMessage = 'Invalid or expired YNAB access token';
2836
- } else if (error.message.includes('403') || error.message.includes('Forbidden')) {
2837
- errorMessage = 'Insufficient permissions to access YNAB data';
2838
- } else if (error.message.includes('404') || error.message.includes('Not Found')) {
2839
- errorMessage = 'Budget, account, category, or transaction not found';
2840
- } else if (error.message.includes('429') || error.message.includes('Too Many Requests')) {
2841
- errorMessage = 'Rate limit exceeded. Please try again later';
2842
- } else if (error.message.includes('500') || error.message.includes('Internal Server Error')) {
2843
- errorMessage = 'YNAB service is currently unavailable';
2844
- }
2845
- }
2846
-
2847
- return {
2848
- content: [
2849
- {
2850
- type: 'text',
2851
- text: responseFormatter.format({
2852
- error: {
2853
- message: errorMessage,
2854
- },
2855
- }),
2856
- },
2857
- ],
2858
- };
2859
- }
2860
-
2861
2081
  /**
2862
2082
  * Registers transaction-domain tools with the provided registry.
2863
2083
  */
@@ -2993,3 +2213,62 @@ export const registerTransactionTools: ToolFactory = (registry, context) => {
2993
2213
  },
2994
2214
  });
2995
2215
  };
2216
+
2217
+ // ============================================================================
2218
+ // Re-exports for backward compatibility
2219
+ // ============================================================================
2220
+
2221
+ /**
2222
+ * Re-export schemas and types from transactionSchemas.ts
2223
+ * These exports maintain backward compatibility for code that imports directly from transactionTools.ts
2224
+ */
2225
+ export {
2226
+ ListTransactionsSchema,
2227
+ type ListTransactionsParams,
2228
+ GetTransactionSchema,
2229
+ type GetTransactionParams,
2230
+ CreateTransactionSchema,
2231
+ type CreateTransactionParams,
2232
+ CreateTransactionsSchema,
2233
+ type CreateTransactionsParams,
2234
+ CreateReceiptSplitTransactionSchema,
2235
+ type CreateReceiptSplitTransactionParams,
2236
+ UpdateTransactionSchema,
2237
+ type UpdateTransactionParams,
2238
+ UpdateTransactionsSchema,
2239
+ type UpdateTransactionsParams,
2240
+ type BulkUpdateTransactionInput,
2241
+ DeleteTransactionSchema,
2242
+ type DeleteTransactionParams,
2243
+ type BulkTransactionResult,
2244
+ type BulkCreateResponse,
2245
+ type BulkUpdateResult,
2246
+ type BulkUpdateResponse,
2247
+ type CorrelationPayload,
2248
+ type CorrelationPayloadInput,
2249
+ type CategorySource,
2250
+ type TransactionCacheInvalidationOptions,
2251
+ type ReceiptCategoryCalculation,
2252
+ type SubtransactionInput,
2253
+ type BulkTransactionInput,
2254
+ } from './transactionSchemas.js';
2255
+
2256
+ /**
2257
+ * Re-export utility functions from transactionUtils.ts
2258
+ * These exports maintain backward compatibility for code that imports directly from transactionTools.ts
2259
+ */
2260
+ export {
2261
+ generateCorrelationKey,
2262
+ toCorrelationPayload,
2263
+ correlateResults,
2264
+ estimatePayloadSize,
2265
+ finalizeResponse,
2266
+ finalizeBulkUpdateResponse,
2267
+ handleTransactionError,
2268
+ toMonthKey,
2269
+ ensureTransaction,
2270
+ appendCategoryIds,
2271
+ collectCategoryIdsFromSources,
2272
+ setsEqual,
2273
+ invalidateTransactionCaches,
2274
+ } from './transactionUtils.js';