@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
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
*
|
|
644
|
-
*
|
|
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
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
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
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
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
|
-
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
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
|
|
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';
|