@dizzlkheinz/ynab-mcpb 0.18.2 → 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 (34) hide show
  1. package/CHANGELOG.md +41 -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 +36 -568
  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__/transactionTools.test.ts +83 -0
  19. package/src/tools/__tests__/transactionUtils.test.ts +989 -0
  20. package/src/tools/reconcileAdapter.ts +6 -0
  21. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +22 -8
  22. package/src/tools/reconciliation/__tests__/adapter.test.ts +3 -0
  23. package/src/tools/reconciliation/__tests__/analyzer.test.ts +65 -0
  24. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +3 -0
  25. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +4 -1
  26. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +3 -0
  27. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +5 -1
  28. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +22 -8
  29. package/src/tools/reconciliation/analyzer.ts +127 -11
  30. package/src/tools/reconciliation/reportFormatter.ts +39 -2
  31. package/src/tools/reconciliation/types.ts +6 -0
  32. package/src/tools/transactionSchemas.ts +453 -0
  33. package/src/tools/transactionTools.ts +152 -835
  34. 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>;
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';
641
59
 
642
60
  /**
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
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
645
66
  */
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
- }
710
-
711
- /**
712
- * Schema for ynab:delete_transaction tool parameters
713
- */
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,25 +435,23 @@ 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
- }
438
+ /**
439
+ * Constants for smart collapse logic
440
+ */
441
+ const BIG_TICKET_THRESHOLD_MILLIUNITS = 50000; // $50.00
442
+ const COLLAPSE_THRESHOLD = 5; // Collapse if 5 or more remaining items
443
+ const MAX_ITEMS_PER_MEMO = 5;
444
+ const MAX_MEMO_LENGTH = 150;
1106
445
 
1107
- interface SubtransactionInput {
1108
- amount: number;
1109
- payee_name?: string;
1110
- payee_id?: string;
1111
- category_id?: string;
1112
- memo?: string;
446
+ /**
447
+ * Truncates a string to fit within maxLength, adding ellipsis if truncated
448
+ */
449
+ function truncateToLength(str: string, maxLength: number): string {
450
+ if (str.length <= maxLength) {
451
+ return str;
452
+ }
453
+ const ellipsis = '...';
454
+ return str.substring(0, maxLength - ellipsis.length) + ellipsis;
1113
455
  }
1114
456
 
1115
457
  function buildItemMemo(item: {
@@ -1118,23 +460,18 @@ function buildItemMemo(item: {
1118
460
  memo: string | undefined;
1119
461
  }): string | undefined {
1120
462
  const quantitySuffix = item.quantity ? ` (x${item.quantity})` : '';
463
+ let result: string;
1121
464
  if (item.memo && item.memo.trim().length > 0) {
1122
- return `${item.name}${quantitySuffix} - ${item.memo}`;
1123
- }
1124
- if (quantitySuffix) {
1125
- return `${item.name}${quantitySuffix}`;
465
+ result = `${item.name}${quantitySuffix} - ${item.memo}`;
466
+ } else if (quantitySuffix) {
467
+ result = `${item.name}${quantitySuffix}`;
468
+ } else {
469
+ result = item.name;
1126
470
  }
1127
- return item.name;
471
+ // Truncate to MAX_MEMO_LENGTH if needed
472
+ return truncateToLength(result, MAX_MEMO_LENGTH);
1128
473
  }
1129
474
 
1130
- /**
1131
- * Constants for smart collapse logic
1132
- */
1133
- const BIG_TICKET_THRESHOLD_MILLIUNITS = 50000; // $50.00
1134
- const COLLAPSE_THRESHOLD = 5; // Collapse if 5 or more remaining items
1135
- const MAX_ITEMS_PER_MEMO = 5;
1136
- const MAX_MEMO_LENGTH = 150;
1137
-
1138
475
  /**
1139
476
  * Applies smart collapse logic to receipt items according to the specification:
1140
477
  * 1. Extract special items (big ticket, returns, discounts)
@@ -1327,10 +664,27 @@ function collapseItemsByCategory(categoryGroup: {
1327
664
  return subtransactions;
1328
665
  }
1329
666
 
667
+ /**
668
+ * Truncates an item name to fit within available space
669
+ * Preserves the amount suffix and adds "..." to indicate truncation
670
+ */
671
+ function truncateItemName(name: string, amountSuffix: string, maxLength: number): string {
672
+ const ellipsis = '...';
673
+ // We need: truncatedName + ellipsis + amountSuffix <= maxLength
674
+ const availableForName = maxLength - ellipsis.length - amountSuffix.length;
675
+
676
+ if (availableForName <= 0) {
677
+ // Edge case: amount suffix alone is too long, just return what we can
678
+ return amountSuffix.substring(0, maxLength);
679
+ }
680
+
681
+ return name.substring(0, availableForName) + ellipsis + amountSuffix;
682
+ }
683
+
1330
684
  /**
1331
685
  * Builds a collapsed memo from a list of items
1332
686
  * Format: "Item1 $X.XX, Item2 $Y.YY, Item3 $Z.ZZ"
1333
- * Truncates with "..." if needed
687
+ * Truncates with "..." if needed (either individual items or the list)
1334
688
  */
1335
689
  function buildCollapsedMemo(items: ReceiptCategoryCalculation['items'][0][]): string {
1336
690
  const parts: string[] = [];
@@ -1340,8 +694,15 @@ function buildCollapsedMemo(items: ReceiptCategoryCalculation['items'][0][]): st
1340
694
  const item = items[i];
1341
695
  if (!item) continue;
1342
696
  const amount = milliunitsToAmount(item.amount_milliunits);
1343
- const itemStr = `${item.name} $${amount.toFixed(2)}`;
697
+ const amountSuffix = ` $${amount.toFixed(2)}`;
698
+ let itemStr = `${item.name}${amountSuffix}`;
1344
699
  const separator = i > 0 ? ', ' : '';
700
+
701
+ // For the first item, check if it alone exceeds the limit
702
+ if (parts.length === 0 && itemStr.length > MAX_MEMO_LENGTH) {
703
+ itemStr = truncateItemName(item.name, amountSuffix, MAX_MEMO_LENGTH);
704
+ }
705
+
1345
706
  const testLength = currentLength + separator.length + itemStr.length;
1346
707
 
1347
708
  // Check if adding this item would exceed limit
@@ -2293,75 +1654,6 @@ async function resolveMetadata(
2293
1654
  return { metadata, unresolvedIds: Array.from(metadataAwaitingResolution), previewDetails };
2294
1655
  }
2295
1656
 
2296
- /**
2297
- * Finalizes bulk update response based on size constraints
2298
- */
2299
- function finalizeBulkUpdateResponse(response: BulkUpdateResponse): BulkUpdateResponse {
2300
- const appendMessage = (message: string | undefined, addition: string): string => {
2301
- if (!message) {
2302
- return addition;
2303
- }
2304
- if (message.includes(addition)) {
2305
- return message;
2306
- }
2307
- return `${message} ${addition}`;
2308
- };
2309
-
2310
- const fullSize = estimatePayloadSize(response);
2311
- if (fullSize <= FULL_RESPONSE_THRESHOLD) {
2312
- return { ...response, mode: 'full' };
2313
- }
2314
-
2315
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
2316
- const { transactions, ...summaryResponse } = response;
2317
- const summaryPayload: BulkUpdateResponse = {
2318
- ...summaryResponse,
2319
- message: appendMessage(
2320
- response.message,
2321
- 'Response downgraded to summary to stay under size limits.',
2322
- ),
2323
- mode: 'summary',
2324
- };
2325
-
2326
- if (estimatePayloadSize(summaryPayload) <= SUMMARY_RESPONSE_THRESHOLD) {
2327
- return summaryPayload;
2328
- }
2329
-
2330
- const idsOnlyPayload: BulkUpdateResponse = {
2331
- ...summaryPayload,
2332
- results: summaryResponse.results.map((result) => {
2333
- const simplified: BulkUpdateResult = {
2334
- request_index: result.request_index,
2335
- status: result.status,
2336
- transaction_id: result.transaction_id,
2337
- correlation_key: result.correlation_key,
2338
- };
2339
- if (result.error) {
2340
- simplified.error = result.error;
2341
- }
2342
- if (result.error_code) {
2343
- simplified.error_code = result.error_code;
2344
- }
2345
- return simplified;
2346
- }),
2347
- message: appendMessage(
2348
- summaryResponse.message,
2349
- 'Response downgraded to ids_only to meet 100KB limit.',
2350
- ),
2351
- mode: 'ids_only',
2352
- };
2353
-
2354
- if (estimatePayloadSize(idsOnlyPayload) <= MAX_RESPONSE_BYTES) {
2355
- return idsOnlyPayload;
2356
- }
2357
-
2358
- throw new ValidationError(
2359
- 'RESPONSE_TOO_LARGE: Unable to format bulk update response within 100KB limit',
2360
- `Batch size: ${response.summary.total_requested} transactions`,
2361
- ['Reduce the batch size and retry', 'Consider splitting into multiple smaller batches'],
2362
- );
2363
- }
2364
-
2365
1657
  /**
2366
1658
  * Handles the ynab:update_transactions tool call
2367
1659
  * Updates multiple transactions in a single batch operation
@@ -2786,40 +2078,6 @@ export async function handleUpdateTransactions(
2786
2078
  )) as CallToolResult;
2787
2079
  }
2788
2080
 
2789
- /**
2790
- * Handles errors from transaction-related API calls
2791
- */
2792
- function handleTransactionError(error: unknown, defaultMessage: string): CallToolResult {
2793
- let errorMessage = defaultMessage;
2794
-
2795
- if (error instanceof Error) {
2796
- if (error.message.includes('401') || error.message.includes('Unauthorized')) {
2797
- errorMessage = 'Invalid or expired YNAB access token';
2798
- } else if (error.message.includes('403') || error.message.includes('Forbidden')) {
2799
- errorMessage = 'Insufficient permissions to access YNAB data';
2800
- } else if (error.message.includes('404') || error.message.includes('Not Found')) {
2801
- errorMessage = 'Budget, account, category, or transaction not found';
2802
- } else if (error.message.includes('429') || error.message.includes('Too Many Requests')) {
2803
- errorMessage = 'Rate limit exceeded. Please try again later';
2804
- } else if (error.message.includes('500') || error.message.includes('Internal Server Error')) {
2805
- errorMessage = 'YNAB service is currently unavailable';
2806
- }
2807
- }
2808
-
2809
- return {
2810
- content: [
2811
- {
2812
- type: 'text',
2813
- text: responseFormatter.format({
2814
- error: {
2815
- message: errorMessage,
2816
- },
2817
- }),
2818
- },
2819
- ],
2820
- };
2821
- }
2822
-
2823
2081
  /**
2824
2082
  * Registers transaction-domain tools with the provided registry.
2825
2083
  */
@@ -2955,3 +2213,62 @@ export const registerTransactionTools: ToolFactory = (registry, context) => {
2955
2213
  },
2956
2214
  });
2957
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';