@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.
- package/CHANGELOG.md +41 -0
- package/dist/bundle/index.cjs +40 -40
- package/dist/tools/reconcileAdapter.js +3 -0
- package/dist/tools/reconciliation/analyzer.js +72 -7
- package/dist/tools/reconciliation/reportFormatter.js +26 -2
- package/dist/tools/reconciliation/types.d.ts +3 -0
- package/dist/tools/transactionSchemas.d.ts +309 -0
- package/dist/tools/transactionSchemas.js +215 -0
- package/dist/tools/transactionTools.d.ts +3 -281
- package/dist/tools/transactionTools.js +36 -568
- package/dist/tools/transactionUtils.d.ts +31 -0
- package/dist/tools/transactionUtils.js +349 -0
- package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
- package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
- package/package.json +4 -2
- package/scripts/run-all-tests.js +196 -0
- package/src/tools/__tests__/transactionSchemas.test.ts +1188 -0
- package/src/tools/__tests__/transactionTools.test.ts +83 -0
- package/src/tools/__tests__/transactionUtils.test.ts +989 -0
- package/src/tools/reconcileAdapter.ts +6 -0
- package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +22 -8
- package/src/tools/reconciliation/__tests__/adapter.test.ts +3 -0
- package/src/tools/reconciliation/__tests__/analyzer.test.ts +65 -0
- package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +3 -0
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +4 -1
- package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +3 -0
- package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +5 -1
- package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +22 -8
- package/src/tools/reconciliation/analyzer.ts +127 -11
- package/src/tools/reconciliation/reportFormatter.ts +39 -2
- package/src/tools/reconciliation/types.ts +6 -0
- package/src/tools/transactionSchemas.ts +453 -0
- package/src/tools/transactionTools.ts +152 -835
- 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
|