@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
@@ -0,0 +1,905 @@
1
+ # TransactionTools Refactoring Implementation Plan
2
+
3
+ > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
4
+
5
+ **Goal:** Split the 2,995-line `transactionTools.ts` into 3 focused files for better maintainability.
6
+
7
+ **Status:** ✅ Completed (2025-12-25)
8
+
9
+ **Architecture:** Extract Zod schemas/types to `transactionSchemas.ts`, cache/correlation utilities to `transactionUtils.ts`, keep all handlers in `transactionTools.ts`. No circular dependencies since handlers stay together.
10
+
11
+ **Outcome:**
12
+ - transactionTools.ts: 2,274 lines (24% reduction from original 2,995 lines)
13
+ - transactionSchemas.ts: 453 lines
14
+ - transactionUtils.ts: 536 lines
15
+ - Total: 3,263 lines (9% overhead from imports/exports)
16
+
17
+ **Tech Stack:** TypeScript, Zod v4, YNAB API types, Vitest
18
+
19
+ ---
20
+
21
+ ## Task 1: Create transactionSchemas.ts
22
+
23
+ **Files:**
24
+ - Create: `src/tools/transactionSchemas.ts`
25
+
26
+ **Step 1: Create the schemas file with all Zod schemas and types**
27
+
28
+ ```typescript
29
+ // src/tools/transactionSchemas.ts
30
+ import { z } from 'zod/v4';
31
+
32
+ // =============================================================================
33
+ // LIST TRANSACTIONS
34
+ // =============================================================================
35
+
36
+ export const ListTransactionsSchema = z
37
+ .object({
38
+ budget_id: z.string().optional(),
39
+ account_id: z.string().optional(),
40
+ since_date: z.string().optional(),
41
+ type: z.enum(['uncategorized', 'unapproved']).optional(),
42
+ last_knowledge_of_server: z.number().optional(),
43
+ })
44
+ .strict();
45
+
46
+ export type ListTransactionsParams = z.infer<typeof ListTransactionsSchema>;
47
+
48
+ // =============================================================================
49
+ // GET TRANSACTION
50
+ // =============================================================================
51
+
52
+ export const GetTransactionSchema = z
53
+ .object({
54
+ budget_id: z.string().optional(),
55
+ transaction_id: z.string(),
56
+ })
57
+ .strict();
58
+
59
+ export type GetTransactionParams = z.infer<typeof GetTransactionSchema>;
60
+
61
+ // =============================================================================
62
+ // CREATE TRANSACTION
63
+ // =============================================================================
64
+
65
+ export const CreateTransactionSchema = z
66
+ .object({
67
+ budget_id: z.string().optional(),
68
+ account_id: z.string(),
69
+ amount: z.number(),
70
+ date: z.string().optional(),
71
+ payee_id: z.string().optional(),
72
+ payee_name: z.string().optional(),
73
+ category_id: z.string().optional(),
74
+ memo: z.string().optional(),
75
+ cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
76
+ approved: z.boolean().optional(),
77
+ flag_color: z
78
+ .enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'none'])
79
+ .optional()
80
+ .nullable(),
81
+ flag_name: z.string().optional().nullable(),
82
+ import_id: z.string().optional(),
83
+ subtransactions: z
84
+ .array(
85
+ z.object({
86
+ amount: z.number(),
87
+ payee_id: z.string().optional(),
88
+ payee_name: z.string().optional(),
89
+ category_id: z.string().optional(),
90
+ memo: z.string().optional(),
91
+ }),
92
+ )
93
+ .optional(),
94
+ })
95
+ .strict();
96
+
97
+ export type CreateTransactionParams = z.infer<typeof CreateTransactionSchema>;
98
+
99
+ // =============================================================================
100
+ // CREATE TRANSACTIONS (BULK)
101
+ // =============================================================================
102
+
103
+ type BulkTransactionInput = Omit<
104
+ z.infer<typeof CreateTransactionSchema>,
105
+ 'budget_id' | 'subtransactions'
106
+ >;
107
+
108
+ export const CreateTransactionsSchema = z
109
+ .object({
110
+ budget_id: z.string().optional(),
111
+ transactions: z.array(CreateTransactionSchema.omit({ budget_id: true, subtransactions: true })),
112
+ })
113
+ .strict();
114
+
115
+ export type CreateTransactionsParams = z.infer<typeof CreateTransactionsSchema>;
116
+
117
+ export interface BulkTransactionResult {
118
+ transaction_id: string;
119
+ date: string;
120
+ amount: number;
121
+ payee_name: string | null | undefined;
122
+ category_name: string | null | undefined;
123
+ memo: string | null | undefined;
124
+ }
125
+
126
+ export interface BulkCreateResponse {
127
+ action: 'create_transactions';
128
+ created_count: number;
129
+ duplicate_count: number;
130
+ transactions: BulkTransactionResult[];
131
+ duplicates: BulkTransactionResult[];
132
+ correlation?: {
133
+ matched: number;
134
+ unmatched: number;
135
+ details: Array<{
136
+ input_index: number;
137
+ status: 'created' | 'duplicate' | 'unmatched';
138
+ transaction?: BulkTransactionResult;
139
+ }>;
140
+ };
141
+ warnings?: string[];
142
+ }
143
+
144
+ // =============================================================================
145
+ // CREATE RECEIPT SPLIT TRANSACTION
146
+ // =============================================================================
147
+
148
+ export const CreateReceiptSplitTransactionSchema = z
149
+ .object({
150
+ budget_id: z.string().optional(),
151
+ account_id: z.string(),
152
+ payee_name: z.string(),
153
+ date: z.string().optional(),
154
+ memo: z.string().optional(),
155
+ cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
156
+ approved: z.boolean().optional(),
157
+ flag_color: z
158
+ .enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'none'])
159
+ .optional()
160
+ .nullable(),
161
+ receipt_subtotal: z.number().optional(),
162
+ receipt_tax: z.number(),
163
+ receipt_total: z.number(),
164
+ categories: z.array(
165
+ z.object({
166
+ category_id: z.string(),
167
+ category_name: z.string().optional(),
168
+ items: z.array(
169
+ z.object({
170
+ name: z.string(),
171
+ amount: z.number(),
172
+ quantity: z.number().optional(),
173
+ memo: z.string().optional(),
174
+ }),
175
+ ),
176
+ }),
177
+ ),
178
+ dry_run: z.boolean().optional(),
179
+ })
180
+ .strict();
181
+
182
+ export type CreateReceiptSplitTransactionParams = z.infer<
183
+ typeof CreateReceiptSplitTransactionSchema
184
+ >;
185
+
186
+ // =============================================================================
187
+ // UPDATE TRANSACTION
188
+ // =============================================================================
189
+
190
+ export const UpdateTransactionSchema = z
191
+ .object({
192
+ budget_id: z.string().optional(),
193
+ transaction_id: z.string(),
194
+ account_id: z.string().optional(),
195
+ amount: z.number().optional(),
196
+ date: z.string().optional(),
197
+ payee_id: z.string().optional().nullable(),
198
+ payee_name: z.string().optional().nullable(),
199
+ category_id: z.string().optional().nullable(),
200
+ memo: z.string().optional().nullable(),
201
+ cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
202
+ approved: z.boolean().optional(),
203
+ flag_color: z
204
+ .enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'none'])
205
+ .optional()
206
+ .nullable(),
207
+ flag_name: z.string().optional().nullable(),
208
+ })
209
+ .strict();
210
+
211
+ export type UpdateTransactionParams = z.infer<typeof UpdateTransactionSchema>;
212
+
213
+ // =============================================================================
214
+ // UPDATE TRANSACTIONS (BULK)
215
+ // =============================================================================
216
+
217
+ export const BulkUpdateTransactionInputSchema = z.object({
218
+ transaction_id: z.string(),
219
+ account_id: z.string().optional(),
220
+ amount: z.number().optional(),
221
+ date: z.string().optional(),
222
+ payee_id: z.string().optional().nullable(),
223
+ payee_name: z.string().optional().nullable(),
224
+ category_id: z.string().optional().nullable(),
225
+ memo: z.string().optional().nullable(),
226
+ cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
227
+ approved: z.boolean().optional(),
228
+ flag_color: z
229
+ .enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple', 'none'])
230
+ .optional()
231
+ .nullable(),
232
+ flag_name: z.string().optional().nullable(),
233
+ });
234
+
235
+ export type BulkUpdateTransactionInput = z.infer<typeof BulkUpdateTransactionInputSchema>;
236
+
237
+ export const UpdateTransactionsSchema = z
238
+ .object({
239
+ budget_id: z.string().optional(),
240
+ transactions: z.array(BulkUpdateTransactionInputSchema),
241
+ })
242
+ .strict();
243
+
244
+ export type UpdateTransactionsParams = z.infer<typeof UpdateTransactionsSchema>;
245
+
246
+ export interface BulkUpdateResult {
247
+ transaction_id: string;
248
+ date: string;
249
+ amount: number;
250
+ payee_name: string | null | undefined;
251
+ category_name: string | null | undefined;
252
+ memo: string | null | undefined;
253
+ updated_fields: string[];
254
+ }
255
+
256
+ export interface BulkUpdateResponse {
257
+ action: 'update_transactions';
258
+ updated_count: number;
259
+ not_found_count: number;
260
+ transactions: BulkUpdateResult[];
261
+ not_found: string[];
262
+ correlation?: {
263
+ matched: number;
264
+ unmatched: number;
265
+ details: Array<{
266
+ input_index: number;
267
+ transaction_id: string;
268
+ status: 'updated' | 'not_found' | 'unmatched';
269
+ transaction?: BulkUpdateResult;
270
+ }>;
271
+ };
272
+ warnings?: string[];
273
+ }
274
+
275
+ // =============================================================================
276
+ // DELETE TRANSACTION
277
+ // =============================================================================
278
+
279
+ export const DeleteTransactionSchema = z
280
+ .object({
281
+ budget_id: z.string().optional(),
282
+ transaction_id: z.string(),
283
+ })
284
+ .strict();
285
+
286
+ export type DeleteTransactionParams = z.infer<typeof DeleteTransactionSchema>;
287
+
288
+ // =============================================================================
289
+ // CORRELATION TYPES
290
+ // =============================================================================
291
+
292
+ export type CorrelationPayload = {
293
+ date: string;
294
+ amount: number;
295
+ payee_name?: string | null;
296
+ memo?: string | null;
297
+ account_id: string;
298
+ };
299
+
300
+ export interface CorrelationPayloadInput {
301
+ date?: string;
302
+ amount: number;
303
+ payee_name?: string | null;
304
+ memo?: string | null;
305
+ account_id: string;
306
+ }
307
+
308
+ // =============================================================================
309
+ // INTERNAL INTERFACES (used by handlers)
310
+ // =============================================================================
311
+
312
+ export interface CategorySource {
313
+ category_id?: string | null;
314
+ subtransactions?: { category_id?: string | null }[] | null | undefined;
315
+ }
316
+
317
+ export interface TransactionCacheInvalidationOptions {
318
+ affectedCategoryIds?: Set<string>;
319
+ invalidateAllCategories?: boolean;
320
+ accountTotalsChanged?: boolean;
321
+ invalidateMonths?: boolean;
322
+ }
323
+
324
+ export interface ReceiptCategoryCalculation {
325
+ category_id: string;
326
+ category_name?: string;
327
+ subtotal_milliunits: number;
328
+ tax_milliunits: number;
329
+ items: Array<{
330
+ name: string;
331
+ amount_milliunits: number;
332
+ quantity?: number;
333
+ memo?: string;
334
+ }>;
335
+ }
336
+
337
+ export interface SubtransactionInput {
338
+ amount: number;
339
+ category_id?: string;
340
+ memo?: string;
341
+ payee_id?: string;
342
+ payee_name?: string;
343
+ }
344
+ ```
345
+
346
+ **Step 2: Verify TypeScript compiles**
347
+
348
+ Run: `npm run type-check`
349
+ Expected: No errors related to transactionSchemas.ts
350
+
351
+ ✅ **Completed**
352
+
353
+ **Step 3: Commit**
354
+
355
+ ```bash
356
+ git add src/tools/transactionSchemas.ts
357
+ git commit -m "refactor: extract transaction schemas to dedicated file"
358
+ ```
359
+
360
+ ✅ **Completed** (Commit: f24e28c)
361
+
362
+ ---
363
+
364
+ ## Task 2: Create transactionUtils.ts
365
+
366
+ **Files:**
367
+ - Create: `src/tools/transactionUtils.ts`
368
+
369
+ **Step 1: Create the utils file with cache and correlation functions**
370
+
371
+ ```typescript
372
+ // src/tools/transactionUtils.ts
373
+ import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
374
+ import { createHash } from 'crypto';
375
+ import type { DeltaCache } from '../server/deltaCache.js';
376
+ import type { ServerKnowledgeStore } from '../server/serverKnowledgeStore.js';
377
+ import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.js';
378
+ import { responseFormatter } from '../server/responseFormatter.js';
379
+ import type {
380
+ CategorySource,
381
+ TransactionCacheInvalidationOptions,
382
+ CorrelationPayload,
383
+ CorrelationPayloadInput,
384
+ BulkTransactionResult,
385
+ BulkCreateResponse,
386
+ BulkUpdateResponse,
387
+ } from './transactionSchemas.js';
388
+
389
+ // =============================================================================
390
+ // TRANSACTION HELPERS
391
+ // =============================================================================
392
+
393
+ /**
394
+ * Utility function to ensure transaction is not null/undefined
395
+ */
396
+ export function ensureTransaction<T>(transaction: T | undefined, errorMessage: string): T {
397
+ if (!transaction) {
398
+ throw new Error(errorMessage);
399
+ }
400
+ return transaction;
401
+ }
402
+
403
+ // =============================================================================
404
+ // CATEGORY HELPERS
405
+ // =============================================================================
406
+
407
+ export function appendCategoryIds(source: CategorySource | undefined, target: Set<string>): void {
408
+ if (!source) {
409
+ return;
410
+ }
411
+ if (source.category_id) {
412
+ target.add(source.category_id);
413
+ }
414
+ if (Array.isArray(source.subtransactions)) {
415
+ for (const sub of source.subtransactions) {
416
+ if (sub?.category_id) {
417
+ target.add(sub.category_id);
418
+ }
419
+ }
420
+ }
421
+ }
422
+
423
+ export function collectCategoryIdsFromSources(
424
+ ...sources: (CategorySource | undefined)[]
425
+ ): Set<string> {
426
+ const result = new Set<string>();
427
+ for (const source of sources) {
428
+ appendCategoryIds(source, result);
429
+ }
430
+ return result;
431
+ }
432
+
433
+ export function setsEqual<T>(a: Set<T>, b: Set<T>): boolean {
434
+ if (a.size !== b.size) {
435
+ return false;
436
+ }
437
+ for (const value of a) {
438
+ if (!b.has(value)) {
439
+ return false;
440
+ }
441
+ }
442
+ return true;
443
+ }
444
+
445
+ // =============================================================================
446
+ // CACHE INVALIDATION
447
+ // =============================================================================
448
+
449
+ const toMonthKey = (date: string): string => `${date.slice(0, 7)}-01`;
450
+
451
+ export function invalidateTransactionCaches(
452
+ deltaCache: DeltaCache,
453
+ knowledgeStore: ServerKnowledgeStore,
454
+ budgetId: string,
455
+ serverKnowledge: number | undefined,
456
+ affectedAccountIds: Set<string>,
457
+ affectedMonths: Set<string>,
458
+ options: TransactionCacheInvalidationOptions = {},
459
+ ): void {
460
+ deltaCache.invalidate(budgetId, 'transactions');
461
+ cacheManager.delete(CacheManager.generateKey('transactions', 'list', budgetId));
462
+
463
+ for (const accountId of affectedAccountIds) {
464
+ const accountPrefix = CacheManager.generateKey('transactions', 'account', budgetId, accountId);
465
+ cacheManager.deleteByPrefix(accountPrefix);
466
+ }
467
+
468
+ const invalidateAccountsList = options.accountTotalsChanged ?? true;
469
+ if (invalidateAccountsList) {
470
+ deltaCache.invalidate(budgetId, 'accounts');
471
+ cacheManager.delete(CacheManager.generateKey('accounts', 'list', budgetId));
472
+ for (const accountId of affectedAccountIds) {
473
+ cacheManager.delete(CacheManager.generateKey('accounts', 'get', budgetId, accountId));
474
+ }
475
+ }
476
+
477
+ const shouldInvalidateMonths = options.invalidateMonths ?? true;
478
+ if (shouldInvalidateMonths) {
479
+ cacheManager.delete(CacheManager.generateKey('months', 'list', budgetId));
480
+ for (const month of affectedMonths) {
481
+ cacheManager.delete(CacheManager.generateKey('months', 'get', budgetId, month));
482
+ }
483
+ }
484
+
485
+ const categoryIds = options.affectedCategoryIds ?? new Set<string>();
486
+ const invalidateAllCategories = options.invalidateAllCategories ?? false;
487
+
488
+ if (invalidateAllCategories) {
489
+ deltaCache.invalidate(budgetId, 'categories');
490
+ cacheManager.delete(CacheManager.generateKey('categories', 'list', budgetId));
491
+ cacheManager.deleteByPrefix(CacheManager.generateKey('categories', 'get', budgetId));
492
+ } else if (categoryIds.size > 0) {
493
+ for (const categoryId of categoryIds) {
494
+ cacheManager.delete(CacheManager.generateKey('categories', 'get', budgetId, categoryId));
495
+ }
496
+ cacheManager.delete(CacheManager.generateKey('categories', 'list', budgetId));
497
+ deltaCache.invalidate(budgetId, 'categories');
498
+ }
499
+
500
+ if (serverKnowledge !== undefined) {
501
+ knowledgeStore.set(`transactions:${budgetId}`, serverKnowledge);
502
+ }
503
+ }
504
+
505
+ // =============================================================================
506
+ // CORRELATION UTILITIES
507
+ // =============================================================================
508
+
509
+ export function generateCorrelationKey(transaction: CorrelationPayload): string {
510
+ const normalized = {
511
+ date: transaction.date,
512
+ amount: transaction.amount,
513
+ payee: (transaction.payee_name ?? '').toLowerCase().trim(),
514
+ memo: (transaction.memo ?? '').toLowerCase().trim(),
515
+ account: transaction.account_id,
516
+ };
517
+ return createHash('sha256').update(JSON.stringify(normalized)).digest('hex').slice(0, 16);
518
+ }
519
+
520
+ export function toCorrelationPayload(transaction: CorrelationPayloadInput): CorrelationPayload {
521
+ return {
522
+ date: transaction.date ?? new Date().toISOString().slice(0, 10),
523
+ amount: transaction.amount,
524
+ payee_name: transaction.payee_name,
525
+ memo: transaction.memo,
526
+ account_id: transaction.account_id,
527
+ };
528
+ }
529
+
530
+ export function correlateResults(
531
+ inputs: CorrelationPayloadInput[],
532
+ results: BulkTransactionResult[],
533
+ duplicates: BulkTransactionResult[] = [],
534
+ ): Map<
535
+ string,
536
+ { input_index: number; status: 'created' | 'duplicate' | 'unmatched'; transaction?: BulkTransactionResult }
537
+ > {
538
+ const correlation = new Map<
539
+ string,
540
+ { input_index: number; status: 'created' | 'duplicate' | 'unmatched'; transaction?: BulkTransactionResult }
541
+ >();
542
+
543
+ const resultsByKey = new Map<string, BulkTransactionResult>();
544
+ for (const result of results) {
545
+ const key = generateCorrelationKey({
546
+ date: result.date,
547
+ amount: result.amount,
548
+ payee_name: result.payee_name,
549
+ memo: result.memo,
550
+ account_id: '', // Results don't have account_id, match without it
551
+ });
552
+ resultsByKey.set(key, result);
553
+ }
554
+
555
+ const duplicatesByKey = new Map<string, BulkTransactionResult>();
556
+ for (const dup of duplicates) {
557
+ const key = generateCorrelationKey({
558
+ date: dup.date,
559
+ amount: dup.amount,
560
+ payee_name: dup.payee_name,
561
+ memo: dup.memo,
562
+ account_id: '',
563
+ });
564
+ duplicatesByKey.set(key, dup);
565
+ }
566
+
567
+ for (let i = 0; i < inputs.length; i++) {
568
+ const input = inputs[i];
569
+ if (!input) continue;
570
+
571
+ const inputKey = generateCorrelationKey(toCorrelationPayload(input));
572
+
573
+ const createdMatch = resultsByKey.get(inputKey);
574
+ if (createdMatch) {
575
+ correlation.set(inputKey, { input_index: i, status: 'created', transaction: createdMatch });
576
+ continue;
577
+ }
578
+
579
+ const duplicateMatch = duplicatesByKey.get(inputKey);
580
+ if (duplicateMatch) {
581
+ correlation.set(inputKey, { input_index: i, status: 'duplicate', transaction: duplicateMatch });
582
+ continue;
583
+ }
584
+
585
+ correlation.set(inputKey, { input_index: i, status: 'unmatched' });
586
+ }
587
+
588
+ return correlation;
589
+ }
590
+
591
+ // =============================================================================
592
+ // RESPONSE UTILITIES
593
+ // =============================================================================
594
+
595
+ export function estimatePayloadSize(payload: BulkCreateResponse | BulkUpdateResponse): number {
596
+ return JSON.stringify(payload).length;
597
+ }
598
+
599
+ export function finalizeResponse(response: BulkCreateResponse): BulkCreateResponse {
600
+ const MAX_RESPONSE_SIZE = 100_000;
601
+ const estimated = estimatePayloadSize(response);
602
+
603
+ if (estimated <= MAX_RESPONSE_SIZE) {
604
+ return response;
605
+ }
606
+
607
+ const truncated = { ...response };
608
+ truncated.warnings = truncated.warnings ?? [];
609
+ truncated.warnings.push(
610
+ `Response truncated: ${response.transactions.length} transactions, ${response.duplicates.length} duplicates`,
611
+ );
612
+
613
+ const transactionLimit = Math.min(50, response.transactions.length);
614
+ const duplicateLimit = Math.min(20, response.duplicates.length);
615
+
616
+ truncated.transactions = response.transactions.slice(0, transactionLimit);
617
+ truncated.duplicates = response.duplicates.slice(0, duplicateLimit);
618
+
619
+ if (truncated.correlation) {
620
+ const detailLimit = Math.min(50, truncated.correlation.details.length);
621
+ truncated.correlation = {
622
+ ...truncated.correlation,
623
+ details: truncated.correlation.details.slice(0, detailLimit),
624
+ };
625
+ }
626
+
627
+ return truncated;
628
+ }
629
+
630
+ export function finalizeBulkUpdateResponse(response: BulkUpdateResponse): BulkUpdateResponse {
631
+ const MAX_RESPONSE_SIZE = 100_000;
632
+ const estimated = estimatePayloadSize(response);
633
+
634
+ if (estimated <= MAX_RESPONSE_SIZE) {
635
+ return response;
636
+ }
637
+
638
+ const truncated = { ...response };
639
+ truncated.warnings = truncated.warnings ?? [];
640
+ truncated.warnings.push(
641
+ `Response truncated: ${response.transactions.length} transactions, ${response.not_found.length} not found`,
642
+ );
643
+
644
+ const transactionLimit = Math.min(50, response.transactions.length);
645
+ const notFoundLimit = Math.min(20, response.not_found.length);
646
+
647
+ truncated.transactions = response.transactions.slice(0, transactionLimit);
648
+ truncated.not_found = response.not_found.slice(0, notFoundLimit);
649
+
650
+ if (truncated.correlation) {
651
+ const detailLimit = Math.min(50, truncated.correlation.details.length);
652
+ truncated.correlation = {
653
+ ...truncated.correlation,
654
+ details: truncated.correlation.details.slice(0, detailLimit),
655
+ };
656
+ }
657
+
658
+ return truncated;
659
+ }
660
+
661
+ // =============================================================================
662
+ // ERROR HANDLING
663
+ // =============================================================================
664
+
665
+ export function handleTransactionError(error: unknown, defaultMessage: string): CallToolResult {
666
+ const message = error instanceof Error ? error.message : defaultMessage;
667
+ return {
668
+ content: [
669
+ {
670
+ type: 'text',
671
+ text: responseFormatter.format({
672
+ error: true,
673
+ message,
674
+ }),
675
+ },
676
+ ],
677
+ isError: true,
678
+ };
679
+ }
680
+ ```
681
+
682
+ **Step 2: Verify TypeScript compiles**
683
+
684
+ Run: `npm run type-check`
685
+ Expected: No errors
686
+
687
+ ✅ **Completed**
688
+
689
+ **Step 3: Commit**
690
+
691
+ ```bash
692
+ git add src/tools/transactionUtils.ts
693
+ git commit -m "refactor: extract transaction utilities to dedicated file"
694
+ ```
695
+
696
+ ✅ **Completed** (Commit: bbc0d76)
697
+
698
+ ---
699
+
700
+ ## Task 3: Update transactionTools.ts imports
701
+
702
+ **Files:**
703
+ - Modify: `src/tools/transactionTools.ts`
704
+
705
+ **Step 1: Replace inline schemas/types with imports from transactionSchemas.ts**
706
+
707
+ At the top of `transactionTools.ts`, replace the schema definitions (lines 143-722) with imports:
708
+
709
+ ```typescript
710
+ // Add these imports after line 20 (after the existing imports)
711
+ import {
712
+ ListTransactionsSchema,
713
+ ListTransactionsParams,
714
+ GetTransactionSchema,
715
+ GetTransactionParams,
716
+ CreateTransactionSchema,
717
+ CreateTransactionParams,
718
+ CreateTransactionsSchema,
719
+ CreateTransactionsParams,
720
+ CreateReceiptSplitTransactionSchema,
721
+ CreateReceiptSplitTransactionParams,
722
+ UpdateTransactionSchema,
723
+ UpdateTransactionParams,
724
+ UpdateTransactionsSchema,
725
+ UpdateTransactionsParams,
726
+ BulkUpdateTransactionInputSchema,
727
+ BulkUpdateTransactionInput,
728
+ DeleteTransactionSchema,
729
+ DeleteTransactionParams,
730
+ BulkTransactionResult,
731
+ BulkCreateResponse,
732
+ BulkUpdateResult,
733
+ BulkUpdateResponse,
734
+ CorrelationPayload,
735
+ CorrelationPayloadInput,
736
+ CategorySource,
737
+ TransactionCacheInvalidationOptions,
738
+ ReceiptCategoryCalculation,
739
+ SubtransactionInput,
740
+ } from './transactionSchemas.js';
741
+
742
+ import {
743
+ ensureTransaction,
744
+ appendCategoryIds,
745
+ collectCategoryIdsFromSources,
746
+ setsEqual,
747
+ invalidateTransactionCaches,
748
+ generateCorrelationKey,
749
+ toCorrelationPayload,
750
+ correlateResults,
751
+ estimatePayloadSize,
752
+ finalizeResponse,
753
+ finalizeBulkUpdateResponse,
754
+ handleTransactionError,
755
+ } from './transactionUtils.js';
756
+ ```
757
+
758
+ **Step 2: Remove the inline definitions**
759
+
760
+ Delete the following sections from `transactionTools.ts`:
761
+ - Lines 25-140: Utility functions (`ensureTransaction`, `appendCategoryIds`, `collectCategoryIdsFromSources`, `setsEqual`, `invalidateTransactionCaches`)
762
+ - Lines 143-722: All schema and type definitions
763
+ - Lines 284-477: Correlation functions (`generateCorrelationKey`, `toCorrelationPayload`, `correlateResults`, `estimatePayloadSize`, `finalizeResponse`)
764
+ - Line 2337: `finalizeBulkUpdateResponse` function
765
+ - Line 2830: `handleTransactionError` function
766
+
767
+ Keep:
768
+ - All handler functions (`handleListTransactions`, `handleGetTransaction`, etc.)
769
+ - Receipt split helper functions (`truncateToLength`, `buildItemMemo`, `applySmartCollapseLogic`, etc.)
770
+ - `registerTransactionTools` factory
771
+
772
+ **Step 3: Re-export schemas for backward compatibility**
773
+
774
+ At the bottom of `transactionTools.ts`, add re-exports:
775
+
776
+ ```typescript
777
+ // Re-export schemas and types for backward compatibility
778
+ export {
779
+ ListTransactionsSchema,
780
+ ListTransactionsParams,
781
+ GetTransactionSchema,
782
+ GetTransactionParams,
783
+ CreateTransactionSchema,
784
+ CreateTransactionParams,
785
+ CreateTransactionsSchema,
786
+ CreateTransactionsParams,
787
+ CreateReceiptSplitTransactionSchema,
788
+ CreateReceiptSplitTransactionParams,
789
+ UpdateTransactionSchema,
790
+ UpdateTransactionParams,
791
+ UpdateTransactionsSchema,
792
+ UpdateTransactionsParams,
793
+ DeleteTransactionSchema,
794
+ DeleteTransactionParams,
795
+ BulkTransactionResult,
796
+ BulkCreateResponse,
797
+ BulkUpdateResult,
798
+ BulkUpdateResponse,
799
+ } from './transactionSchemas.js';
800
+
801
+ export {
802
+ generateCorrelationKey,
803
+ toCorrelationPayload,
804
+ correlateResults,
805
+ } from './transactionUtils.js';
806
+ ```
807
+
808
+ **Step 4: Verify TypeScript compiles**
809
+
810
+ Run: `npm run type-check`
811
+ Expected: No errors
812
+
813
+ **Step 5: Commit**
814
+
815
+ ```bash
816
+ git add src/tools/transactionTools.ts
817
+ git commit -m "refactor: update transactionTools to use extracted modules"
818
+ ```
819
+
820
+ ✅ **Completed** (Commit: 6788c84)
821
+
822
+ ---
823
+
824
+ ## Task 4: Run full test suite
825
+
826
+ **Files:**
827
+ - Test: `src/tools/__tests__/transactionTools.test.ts`
828
+ - Test: `src/tools/__tests__/transactionTools.integration.test.ts`
829
+
830
+ **Step 1: Run unit tests**
831
+
832
+ Run: `npm run test:unit`
833
+ Expected: All tests pass
834
+
835
+ **Step 2: Run integration tests**
836
+
837
+ Run: `npm run test:integration:transactions`
838
+ Expected: All tests pass
839
+
840
+ **Step 3: Run full test suite**
841
+
842
+ Run: `npm test`
843
+ Expected: All 5,212+ lines of transaction tests pass
844
+
845
+ **Step 4: Commit test verification**
846
+
847
+ No changes needed if tests pass. If any test imports need updating:
848
+
849
+ ```bash
850
+ git add -A
851
+ git commit -m "test: update imports for refactored transaction modules"
852
+ ```
853
+
854
+ ---
855
+
856
+ ## Task 5: Verify build and lint
857
+
858
+ **Step 1: Run linter**
859
+
860
+ Run: `npm run lint`
861
+ Expected: No errors
862
+
863
+ **Step 2: Run full build**
864
+
865
+ Run: `npm run build`
866
+ Expected: Build succeeds
867
+
868
+ **Step 3: Verify line counts**
869
+
870
+ Run: `wc -l src/tools/transactionTools.ts src/tools/transactionSchemas.ts src/tools/transactionUtils.ts`
871
+
872
+ Expected output (approximate):
873
+ ```
874
+ ~2000 src/tools/transactionTools.ts
875
+ ~600 src/tools/transactionSchemas.ts
876
+ ~200 src/tools/transactionUtils.ts
877
+ ~2800 total
878
+ ```
879
+
880
+ **Step 4: Final commit**
881
+
882
+ ```bash
883
+ git add -A
884
+ git commit -m "refactor: complete transactionTools modularization
885
+
886
+ Split 2,995-line transactionTools.ts into 3 focused files:
887
+ - transactionSchemas.ts (~600 lines) - Zod schemas and types
888
+ - transactionUtils.ts (~200 lines) - Cache/correlation utilities
889
+ - transactionTools.ts (~2,000 lines) - Handlers + registration
890
+
891
+ 30% reduction in main file size. All tests passing."
892
+ ```
893
+
894
+ ---
895
+
896
+ ## Success Criteria
897
+
898
+ - [x] `transactionTools.ts` reduced from 2,995 to ~2,000 lines - **Actual: 2,274 lines (24% reduction)**
899
+ - [x] `transactionSchemas.ts` contains all schemas (~600 lines) - **Actual: 453 lines**
900
+ - [x] `transactionUtils.ts` contains utilities (~200 lines) - **Actual: 536 lines**
901
+ - [x] All unit tests pass
902
+ - [x] All integration tests pass
903
+ - [x] `npm run build` succeeds
904
+ - [x] `npm run lint` passes
905
+ - [x] No circular dependency warnings