@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
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { z } from 'zod/v4';
|
|
2
|
-
import { createHash } from 'crypto';
|
|
3
1
|
import { ValidationError, withToolErrorHandling } from '../types/index.js';
|
|
4
2
|
import { createAdapters, createBudgetResolver } from './adapters.js';
|
|
5
3
|
import { ToolAnnotationPresets } from './toolCategories.js';
|
|
@@ -9,483 +7,8 @@ import { cacheManager, CACHE_TTLS, CacheManager } from '../server/cacheManager.j
|
|
|
9
7
|
import { globalRequestLogger } from '../server/requestLogger.js';
|
|
10
8
|
import { resolveDeltaFetcherArgs, resolveDeltaWriteArgs } from './deltaSupport.js';
|
|
11
9
|
import { handleExportTransactions, ExportTransactionsSchema } from './exportTransactions.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
throw new Error(errorMessage);
|
|
15
|
-
}
|
|
16
|
-
return transaction;
|
|
17
|
-
}
|
|
18
|
-
const toMonthKey = (date) => `${date.slice(0, 7)}-01`;
|
|
19
|
-
function appendCategoryIds(source, target) {
|
|
20
|
-
if (!source) {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
if (source.category_id) {
|
|
24
|
-
target.add(source.category_id);
|
|
25
|
-
}
|
|
26
|
-
if (Array.isArray(source.subtransactions)) {
|
|
27
|
-
for (const sub of source.subtransactions) {
|
|
28
|
-
if (sub?.category_id) {
|
|
29
|
-
target.add(sub.category_id);
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
function collectCategoryIdsFromSources(...sources) {
|
|
35
|
-
const result = new Set();
|
|
36
|
-
for (const source of sources) {
|
|
37
|
-
appendCategoryIds(source, result);
|
|
38
|
-
}
|
|
39
|
-
return result;
|
|
40
|
-
}
|
|
41
|
-
function setsEqual(a, b) {
|
|
42
|
-
if (a.size !== b.size) {
|
|
43
|
-
return false;
|
|
44
|
-
}
|
|
45
|
-
for (const value of a) {
|
|
46
|
-
if (!b.has(value)) {
|
|
47
|
-
return false;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return true;
|
|
51
|
-
}
|
|
52
|
-
function invalidateTransactionCaches(deltaCache, knowledgeStore, budgetId, serverKnowledge, affectedAccountIds, affectedMonths, options = {}) {
|
|
53
|
-
deltaCache.invalidate(budgetId, 'transactions');
|
|
54
|
-
cacheManager.delete(CacheManager.generateKey('transactions', 'list', budgetId));
|
|
55
|
-
for (const accountId of affectedAccountIds) {
|
|
56
|
-
const accountPrefix = CacheManager.generateKey('transactions', 'account', budgetId, accountId);
|
|
57
|
-
cacheManager.deleteByPrefix(accountPrefix);
|
|
58
|
-
}
|
|
59
|
-
const invalidateAccountsList = options.accountTotalsChanged ?? true;
|
|
60
|
-
if (invalidateAccountsList) {
|
|
61
|
-
cacheManager.delete(CacheManager.generateKey('accounts', 'list', budgetId));
|
|
62
|
-
}
|
|
63
|
-
for (const accountId of affectedAccountIds) {
|
|
64
|
-
cacheManager.delete(CacheManager.generateKey('account', 'get', budgetId, accountId));
|
|
65
|
-
}
|
|
66
|
-
const affectedCategoryIds = options.affectedCategoryIds ?? new Set();
|
|
67
|
-
const shouldInvalidateCategories = options.invalidateAllCategories || affectedCategoryIds.size > 0;
|
|
68
|
-
if (shouldInvalidateCategories) {
|
|
69
|
-
cacheManager.delete(CacheManager.generateKey('categories', 'list', budgetId));
|
|
70
|
-
for (const categoryId of affectedCategoryIds) {
|
|
71
|
-
cacheManager.delete(CacheManager.generateKey('category', 'get', budgetId, categoryId));
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
const shouldInvalidateMonths = options.invalidateMonths ?? affectedMonths.size > 0;
|
|
75
|
-
if (shouldInvalidateMonths) {
|
|
76
|
-
cacheManager.delete(CacheManager.generateKey('months', 'list', budgetId));
|
|
77
|
-
deltaCache.invalidate(budgetId, 'months');
|
|
78
|
-
for (const month of affectedMonths) {
|
|
79
|
-
cacheManager.delete(CacheManager.generateKey('month', 'get', budgetId, month));
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
if (serverKnowledge !== undefined) {
|
|
83
|
-
const transactionCacheKey = CacheManager.generateKey('transactions', 'list', budgetId);
|
|
84
|
-
knowledgeStore.update(transactionCacheKey, serverKnowledge);
|
|
85
|
-
if (invalidateAccountsList) {
|
|
86
|
-
const accountsCacheKey = CacheManager.generateKey('accounts', 'list', budgetId);
|
|
87
|
-
knowledgeStore.update(accountsCacheKey, serverKnowledge);
|
|
88
|
-
}
|
|
89
|
-
if (shouldInvalidateMonths && affectedMonths.size > 0) {
|
|
90
|
-
const monthsCacheKey = CacheManager.generateKey('months', 'list', budgetId);
|
|
91
|
-
knowledgeStore.update(monthsCacheKey, serverKnowledge);
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
export const ListTransactionsSchema = z
|
|
96
|
-
.object({
|
|
97
|
-
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
98
|
-
account_id: z.string().optional(),
|
|
99
|
-
category_id: z.string().optional(),
|
|
100
|
-
since_date: z
|
|
101
|
-
.string()
|
|
102
|
-
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
|
|
103
|
-
.optional(),
|
|
104
|
-
type: z.enum(['uncategorized', 'unapproved']).optional(),
|
|
105
|
-
})
|
|
106
|
-
.strict();
|
|
107
|
-
export const GetTransactionSchema = z
|
|
108
|
-
.object({
|
|
109
|
-
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
110
|
-
transaction_id: z.string().min(1, 'Transaction ID is required'),
|
|
111
|
-
})
|
|
112
|
-
.strict();
|
|
113
|
-
export const CreateTransactionSchema = z
|
|
114
|
-
.object({
|
|
115
|
-
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
116
|
-
account_id: z.string().min(1, 'Account ID is required'),
|
|
117
|
-
amount: z.number().int('Amount must be an integer in milliunits'),
|
|
118
|
-
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)'),
|
|
119
|
-
payee_name: z.string().optional(),
|
|
120
|
-
payee_id: z.string().optional(),
|
|
121
|
-
category_id: z.string().optional(),
|
|
122
|
-
memo: z.string().optional(),
|
|
123
|
-
cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
|
|
124
|
-
approved: z.boolean().optional(),
|
|
125
|
-
flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional(),
|
|
126
|
-
import_id: z.string().min(1, 'Import ID cannot be empty').optional(),
|
|
127
|
-
dry_run: z.boolean().optional(),
|
|
128
|
-
subtransactions: z
|
|
129
|
-
.array(z
|
|
130
|
-
.object({
|
|
131
|
-
amount: z.number().int('Subtransaction amount must be an integer in milliunits'),
|
|
132
|
-
payee_name: z.string().optional(),
|
|
133
|
-
payee_id: z.string().optional(),
|
|
134
|
-
category_id: z.string().optional(),
|
|
135
|
-
memo: z.string().optional(),
|
|
136
|
-
})
|
|
137
|
-
.strict())
|
|
138
|
-
.min(1, 'At least one subtransaction is required when provided')
|
|
139
|
-
.optional(),
|
|
140
|
-
})
|
|
141
|
-
.strict()
|
|
142
|
-
.superRefine((data, ctx) => {
|
|
143
|
-
if (data.subtransactions && data.subtransactions.length > 0) {
|
|
144
|
-
const total = data.subtransactions.reduce((sum, sub) => sum + sub.amount, 0);
|
|
145
|
-
if (total !== data.amount) {
|
|
146
|
-
ctx.addIssue({
|
|
147
|
-
code: z.ZodIssueCode.custom,
|
|
148
|
-
message: 'Amount must equal the sum of subtransaction amounts',
|
|
149
|
-
path: ['amount'],
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
const BulkTransactionInputSchemaBase = CreateTransactionSchema.pick({
|
|
155
|
-
account_id: true,
|
|
156
|
-
amount: true,
|
|
157
|
-
date: true,
|
|
158
|
-
payee_name: true,
|
|
159
|
-
payee_id: true,
|
|
160
|
-
category_id: true,
|
|
161
|
-
memo: true,
|
|
162
|
-
cleared: true,
|
|
163
|
-
approved: true,
|
|
164
|
-
flag_color: true,
|
|
165
|
-
import_id: true,
|
|
166
|
-
});
|
|
167
|
-
const BulkTransactionInputSchema = BulkTransactionInputSchemaBase.strict();
|
|
168
|
-
export const CreateTransactionsSchema = z
|
|
169
|
-
.object({
|
|
170
|
-
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
171
|
-
transactions: z
|
|
172
|
-
.array(BulkTransactionInputSchema)
|
|
173
|
-
.min(1, 'At least one transaction is required')
|
|
174
|
-
.max(100, 'A maximum of 100 transactions may be created at once'),
|
|
175
|
-
dry_run: z.boolean().optional(),
|
|
176
|
-
})
|
|
177
|
-
.strict();
|
|
178
|
-
const FULL_RESPONSE_THRESHOLD = 64 * 1024;
|
|
179
|
-
const SUMMARY_RESPONSE_THRESHOLD = 96 * 1024;
|
|
180
|
-
const MAX_RESPONSE_BYTES = 100 * 1024;
|
|
181
|
-
export function generateCorrelationKey(transaction) {
|
|
182
|
-
if (transaction.import_id) {
|
|
183
|
-
return transaction.import_id;
|
|
184
|
-
}
|
|
185
|
-
const segments = [
|
|
186
|
-
`account:${transaction.account_id ?? ''}`,
|
|
187
|
-
`date:${transaction.date ?? ''}`,
|
|
188
|
-
`amount:${transaction.amount ?? 0}`,
|
|
189
|
-
`payee:${transaction.payee_id ?? transaction.payee_name ?? ''}`,
|
|
190
|
-
`category:${transaction.category_id ?? ''}`,
|
|
191
|
-
`memo:${transaction.memo ?? ''}`,
|
|
192
|
-
`cleared:${transaction.cleared ?? ''}`,
|
|
193
|
-
`approved:${transaction.approved ?? false}`,
|
|
194
|
-
`flag:${transaction.flag_color ?? ''}`,
|
|
195
|
-
];
|
|
196
|
-
const normalized = segments.join('|');
|
|
197
|
-
const hash = createHash('sha256').update(normalized).digest('hex').slice(0, 16);
|
|
198
|
-
return `hash:${hash}`;
|
|
199
|
-
}
|
|
200
|
-
export function toCorrelationPayload(transaction) {
|
|
201
|
-
const payload = {};
|
|
202
|
-
if (transaction.account_id !== undefined) {
|
|
203
|
-
payload.account_id = transaction.account_id;
|
|
204
|
-
}
|
|
205
|
-
if (transaction.date !== undefined) {
|
|
206
|
-
payload.date = transaction.date;
|
|
207
|
-
}
|
|
208
|
-
if (transaction.amount !== undefined) {
|
|
209
|
-
payload.amount = transaction.amount;
|
|
210
|
-
}
|
|
211
|
-
if (transaction.cleared !== undefined) {
|
|
212
|
-
payload.cleared = transaction.cleared;
|
|
213
|
-
}
|
|
214
|
-
if (transaction.approved !== undefined) {
|
|
215
|
-
payload.approved = transaction.approved;
|
|
216
|
-
}
|
|
217
|
-
if (transaction.flag_color !== undefined) {
|
|
218
|
-
payload.flag_color = transaction.flag_color;
|
|
219
|
-
}
|
|
220
|
-
payload.payee_id = transaction.payee_id ?? null;
|
|
221
|
-
payload.payee_name = transaction.payee_name ?? null;
|
|
222
|
-
payload.category_id = transaction.category_id ?? null;
|
|
223
|
-
payload.memo = transaction.memo ?? null;
|
|
224
|
-
payload.import_id = transaction.import_id ?? null;
|
|
225
|
-
return payload;
|
|
226
|
-
}
|
|
227
|
-
export function correlateResults(requests, responseData, duplicateImportIds) {
|
|
228
|
-
const createdByImportId = new Map();
|
|
229
|
-
const createdByHash = new Map();
|
|
230
|
-
const responseTransactions = responseData.transactions ?? [];
|
|
231
|
-
const register = (map, key, transactionId) => {
|
|
232
|
-
const existing = map.get(key);
|
|
233
|
-
if (existing) {
|
|
234
|
-
existing.push(transactionId);
|
|
235
|
-
return;
|
|
236
|
-
}
|
|
237
|
-
map.set(key, [transactionId]);
|
|
238
|
-
};
|
|
239
|
-
for (const transaction of responseTransactions) {
|
|
240
|
-
if (!transaction.id) {
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
const key = generateCorrelationKey(transaction);
|
|
244
|
-
if (key.startsWith('hash:')) {
|
|
245
|
-
register(createdByHash, key, transaction.id);
|
|
246
|
-
}
|
|
247
|
-
else {
|
|
248
|
-
register(createdByImportId, key, transaction.id);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
const popId = (map, key) => {
|
|
252
|
-
const bucket = map.get(key);
|
|
253
|
-
if (!bucket || bucket.length === 0) {
|
|
254
|
-
return undefined;
|
|
255
|
-
}
|
|
256
|
-
const [transactionId] = bucket.splice(0, 1);
|
|
257
|
-
if (bucket.length === 0) {
|
|
258
|
-
map.delete(key);
|
|
259
|
-
}
|
|
260
|
-
return transactionId;
|
|
261
|
-
};
|
|
262
|
-
const correlatedResults = [];
|
|
263
|
-
for (const [index, transaction] of requests.entries()) {
|
|
264
|
-
const normalizedRequest = toCorrelationPayload(transaction);
|
|
265
|
-
const correlationKey = generateCorrelationKey(normalizedRequest);
|
|
266
|
-
if (transaction.import_id && duplicateImportIds.has(transaction.import_id)) {
|
|
267
|
-
correlatedResults.push({
|
|
268
|
-
request_index: index,
|
|
269
|
-
status: 'duplicate',
|
|
270
|
-
correlation_key: correlationKey,
|
|
271
|
-
});
|
|
272
|
-
continue;
|
|
273
|
-
}
|
|
274
|
-
let transactionId;
|
|
275
|
-
if (correlationKey.startsWith('hash:')) {
|
|
276
|
-
transactionId = popId(createdByHash, correlationKey);
|
|
277
|
-
}
|
|
278
|
-
else {
|
|
279
|
-
transactionId = popId(createdByImportId, correlationKey);
|
|
280
|
-
}
|
|
281
|
-
if (!transactionId && !correlationKey.startsWith('hash:')) {
|
|
282
|
-
const hashKey = generateCorrelationKey(toCorrelationPayload({ ...transaction, import_id: undefined }));
|
|
283
|
-
transactionId = popId(createdByHash, hashKey);
|
|
284
|
-
}
|
|
285
|
-
if (transactionId) {
|
|
286
|
-
const successResult = {
|
|
287
|
-
request_index: index,
|
|
288
|
-
status: 'created',
|
|
289
|
-
correlation_key: correlationKey,
|
|
290
|
-
};
|
|
291
|
-
successResult.transaction_id = transactionId;
|
|
292
|
-
correlatedResults.push(successResult);
|
|
293
|
-
continue;
|
|
294
|
-
}
|
|
295
|
-
globalRequestLogger.logError('ynab:create_transactions', 'correlate_results', {
|
|
296
|
-
request_index: index,
|
|
297
|
-
correlation_key: correlationKey,
|
|
298
|
-
request: {
|
|
299
|
-
account_id: transaction.account_id,
|
|
300
|
-
date: transaction.date,
|
|
301
|
-
amount: transaction.amount,
|
|
302
|
-
import_id: transaction.import_id,
|
|
303
|
-
},
|
|
304
|
-
}, 'correlation_failed');
|
|
305
|
-
correlatedResults.push({
|
|
306
|
-
request_index: index,
|
|
307
|
-
status: 'failed',
|
|
308
|
-
correlation_key: correlationKey,
|
|
309
|
-
error_code: 'correlation_failed',
|
|
310
|
-
error: 'Unable to correlate request transaction with YNAB response',
|
|
311
|
-
});
|
|
312
|
-
}
|
|
313
|
-
return correlatedResults;
|
|
314
|
-
}
|
|
315
|
-
function estimatePayloadSize(payload) {
|
|
316
|
-
return Buffer.byteLength(JSON.stringify(payload), 'utf8');
|
|
317
|
-
}
|
|
318
|
-
function finalizeResponse(response) {
|
|
319
|
-
const appendMessage = (message, addition) => {
|
|
320
|
-
if (!message) {
|
|
321
|
-
return addition;
|
|
322
|
-
}
|
|
323
|
-
if (message.includes(addition)) {
|
|
324
|
-
return message;
|
|
325
|
-
}
|
|
326
|
-
return `${message} ${addition}`;
|
|
327
|
-
};
|
|
328
|
-
const fullSize = estimatePayloadSize({ ...response, mode: 'full' });
|
|
329
|
-
if (fullSize <= FULL_RESPONSE_THRESHOLD) {
|
|
330
|
-
return { ...response, mode: 'full' };
|
|
331
|
-
}
|
|
332
|
-
const { transactions, ...summaryResponse } = response;
|
|
333
|
-
const summaryPayload = {
|
|
334
|
-
...summaryResponse,
|
|
335
|
-
message: appendMessage(response.message, 'Response downgraded to summary to stay under size limits.'),
|
|
336
|
-
mode: 'summary',
|
|
337
|
-
};
|
|
338
|
-
if (estimatePayloadSize(summaryPayload) <= SUMMARY_RESPONSE_THRESHOLD) {
|
|
339
|
-
return summaryPayload;
|
|
340
|
-
}
|
|
341
|
-
const idsOnlyPayload = {
|
|
342
|
-
...summaryPayload,
|
|
343
|
-
results: summaryResponse.results.map((result) => ({
|
|
344
|
-
request_index: result.request_index,
|
|
345
|
-
status: result.status,
|
|
346
|
-
transaction_id: result.transaction_id,
|
|
347
|
-
correlation_key: result.correlation_key,
|
|
348
|
-
error: result.error,
|
|
349
|
-
})),
|
|
350
|
-
message: appendMessage(summaryResponse.message, 'Response downgraded to ids_only to meet 100KB limit.'),
|
|
351
|
-
mode: 'ids_only',
|
|
352
|
-
};
|
|
353
|
-
if (estimatePayloadSize(idsOnlyPayload) <= MAX_RESPONSE_BYTES) {
|
|
354
|
-
return idsOnlyPayload;
|
|
355
|
-
}
|
|
356
|
-
throw new ValidationError('RESPONSE_TOO_LARGE: Unable to format bulk create response within 100KB limit', `Batch size: ${response.summary.total_requested} transactions`, ['Reduce the batch size and retry', 'Consider splitting into multiple smaller batches']);
|
|
357
|
-
}
|
|
358
|
-
const ReceiptSplitItemSchema = z
|
|
359
|
-
.object({
|
|
360
|
-
name: z.string().min(1, 'Item name is required'),
|
|
361
|
-
amount: z.number().finite('Item amount must be a finite number'),
|
|
362
|
-
quantity: z
|
|
363
|
-
.number()
|
|
364
|
-
.finite('Quantity must be a finite number')
|
|
365
|
-
.positive('Quantity must be greater than zero')
|
|
366
|
-
.optional(),
|
|
367
|
-
memo: z.string().optional(),
|
|
368
|
-
})
|
|
369
|
-
.strict();
|
|
370
|
-
const ReceiptSplitCategorySchema = z
|
|
371
|
-
.object({
|
|
372
|
-
category_id: z.string().min(1, 'Category ID is required'),
|
|
373
|
-
category_name: z.string().optional(),
|
|
374
|
-
items: z.array(ReceiptSplitItemSchema).min(1, 'Each category must include at least one item'),
|
|
375
|
-
})
|
|
376
|
-
.strict();
|
|
377
|
-
export const CreateReceiptSplitTransactionSchema = z
|
|
378
|
-
.object({
|
|
379
|
-
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
380
|
-
account_id: z.string().min(1, 'Account ID is required'),
|
|
381
|
-
payee_name: z.string().min(1, 'Payee name is required'),
|
|
382
|
-
date: z
|
|
383
|
-
.string()
|
|
384
|
-
.regex(/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
|
|
385
|
-
.optional(),
|
|
386
|
-
memo: z.string().optional(),
|
|
387
|
-
receipt_subtotal: z
|
|
388
|
-
.number()
|
|
389
|
-
.finite('Receipt subtotal must be a finite number')
|
|
390
|
-
.refine((value) => value >= 0, 'Receipt subtotal must be zero or greater')
|
|
391
|
-
.optional(),
|
|
392
|
-
receipt_tax: z.number().finite('Receipt tax must be a finite number'),
|
|
393
|
-
receipt_total: z
|
|
394
|
-
.number()
|
|
395
|
-
.finite('Receipt total must be a finite number')
|
|
396
|
-
.refine((value) => value > 0, 'Receipt total must be greater than zero'),
|
|
397
|
-
categories: z
|
|
398
|
-
.array(ReceiptSplitCategorySchema)
|
|
399
|
-
.min(1, 'At least one categorized group is required to create a split transaction'),
|
|
400
|
-
cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
|
|
401
|
-
approved: z.boolean().optional(),
|
|
402
|
-
flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional(),
|
|
403
|
-
dry_run: z.boolean().optional(),
|
|
404
|
-
})
|
|
405
|
-
.strict()
|
|
406
|
-
.superRefine((data, ctx) => {
|
|
407
|
-
const itemsSubtotal = data.categories
|
|
408
|
-
.flatMap((category) => category.items)
|
|
409
|
-
.reduce((sum, item) => sum + item.amount, 0);
|
|
410
|
-
if (data.receipt_subtotal !== undefined) {
|
|
411
|
-
const delta = Math.abs(data.receipt_subtotal - itemsSubtotal);
|
|
412
|
-
if (delta > 0.01) {
|
|
413
|
-
ctx.addIssue({
|
|
414
|
-
code: z.ZodIssueCode.custom,
|
|
415
|
-
message: `Receipt subtotal (${data.receipt_subtotal.toFixed(2)}) does not match categorized items total (${itemsSubtotal.toFixed(2)})`,
|
|
416
|
-
path: ['receipt_subtotal'],
|
|
417
|
-
});
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
const expectedTotal = itemsSubtotal + data.receipt_tax;
|
|
421
|
-
const deltaTotal = Math.abs(expectedTotal - data.receipt_total);
|
|
422
|
-
if (deltaTotal > 0.01) {
|
|
423
|
-
ctx.addIssue({
|
|
424
|
-
code: z.ZodIssueCode.custom,
|
|
425
|
-
message: `Receipt total (${data.receipt_total.toFixed(2)}) does not match subtotal plus tax (${expectedTotal.toFixed(2)})`,
|
|
426
|
-
path: ['receipt_total'],
|
|
427
|
-
});
|
|
428
|
-
}
|
|
429
|
-
});
|
|
430
|
-
export const UpdateTransactionSchema = z
|
|
431
|
-
.object({
|
|
432
|
-
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
433
|
-
transaction_id: z.string().min(1, 'Transaction ID is required'),
|
|
434
|
-
account_id: z.string().optional(),
|
|
435
|
-
amount: z.number().int('Amount must be an integer in milliunits').optional(),
|
|
436
|
-
date: z
|
|
437
|
-
.string()
|
|
438
|
-
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
|
|
439
|
-
.optional(),
|
|
440
|
-
payee_name: z.string().optional(),
|
|
441
|
-
payee_id: z.string().optional(),
|
|
442
|
-
category_id: z.string().optional(),
|
|
443
|
-
memo: z.string().optional(),
|
|
444
|
-
cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
|
|
445
|
-
approved: z.boolean().optional(),
|
|
446
|
-
flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional(),
|
|
447
|
-
dry_run: z.boolean().optional(),
|
|
448
|
-
})
|
|
449
|
-
.strict();
|
|
450
|
-
const BulkUpdateTransactionInputSchema = z
|
|
451
|
-
.object({
|
|
452
|
-
id: z.string().min(1, 'Transaction ID is required'),
|
|
453
|
-
amount: z.number().int('Amount must be an integer in milliunits').optional(),
|
|
454
|
-
date: z
|
|
455
|
-
.string()
|
|
456
|
-
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
|
|
457
|
-
.optional(),
|
|
458
|
-
payee_name: z.string().optional(),
|
|
459
|
-
payee_id: z.string().optional(),
|
|
460
|
-
category_id: z.string().optional(),
|
|
461
|
-
memo: z.string().optional(),
|
|
462
|
-
cleared: z.enum(['cleared', 'uncleared', 'reconciled']).optional(),
|
|
463
|
-
approved: z.boolean().optional(),
|
|
464
|
-
flag_color: z.enum(['red', 'orange', 'yellow', 'green', 'blue', 'purple']).optional(),
|
|
465
|
-
original_account_id: z.string().optional(),
|
|
466
|
-
original_date: z
|
|
467
|
-
.string()
|
|
468
|
-
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Date must be in ISO format (YYYY-MM-DD)')
|
|
469
|
-
.optional(),
|
|
470
|
-
})
|
|
471
|
-
.strict();
|
|
472
|
-
export const UpdateTransactionsSchema = z
|
|
473
|
-
.object({
|
|
474
|
-
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
475
|
-
transactions: z
|
|
476
|
-
.array(BulkUpdateTransactionInputSchema)
|
|
477
|
-
.min(1, 'At least one transaction is required')
|
|
478
|
-
.max(100, 'A maximum of 100 transactions may be updated at once'),
|
|
479
|
-
dry_run: z.boolean().optional(),
|
|
480
|
-
})
|
|
481
|
-
.strict();
|
|
482
|
-
export const DeleteTransactionSchema = z
|
|
483
|
-
.object({
|
|
484
|
-
budget_id: z.string().min(1, 'Budget ID is required'),
|
|
485
|
-
transaction_id: z.string().min(1, 'Transaction ID is required'),
|
|
486
|
-
dry_run: z.boolean().optional(),
|
|
487
|
-
})
|
|
488
|
-
.strict();
|
|
10
|
+
import { ListTransactionsSchema, GetTransactionSchema, CreateTransactionSchema, CreateTransactionsSchema, CreateReceiptSplitTransactionSchema, UpdateTransactionSchema, UpdateTransactionsSchema, DeleteTransactionSchema, } from './transactionSchemas.js';
|
|
11
|
+
import { ensureTransaction, appendCategoryIds, collectCategoryIdsFromSources, setsEqual, invalidateTransactionCaches, correlateResults, finalizeResponse, finalizeBulkUpdateResponse, handleTransactionError, toMonthKey, } from './transactionUtils.js';
|
|
489
12
|
export async function handleListTransactions(ynabAPI, deltaFetcherOrParams, maybeParams) {
|
|
490
13
|
const { deltaFetcher, params } = resolveDeltaFetcherArgs(ynabAPI, deltaFetcherOrParams, maybeParams);
|
|
491
14
|
return await withToolErrorHandling(async () => {
|
|
@@ -749,20 +272,31 @@ export async function handleCreateTransaction(ynabAPI, deltaCacheOrParams, knowl
|
|
|
749
272
|
return handleTransactionError(error, 'Failed to create transaction');
|
|
750
273
|
}
|
|
751
274
|
}
|
|
275
|
+
const BIG_TICKET_THRESHOLD_MILLIUNITS = 50000;
|
|
276
|
+
const COLLAPSE_THRESHOLD = 5;
|
|
277
|
+
const MAX_ITEMS_PER_MEMO = 5;
|
|
278
|
+
const MAX_MEMO_LENGTH = 150;
|
|
279
|
+
function truncateToLength(str, maxLength) {
|
|
280
|
+
if (str.length <= maxLength) {
|
|
281
|
+
return str;
|
|
282
|
+
}
|
|
283
|
+
const ellipsis = '...';
|
|
284
|
+
return str.substring(0, maxLength - ellipsis.length) + ellipsis;
|
|
285
|
+
}
|
|
752
286
|
function buildItemMemo(item) {
|
|
753
287
|
const quantitySuffix = item.quantity ? ` (x${item.quantity})` : '';
|
|
288
|
+
let result;
|
|
754
289
|
if (item.memo && item.memo.trim().length > 0) {
|
|
755
|
-
|
|
290
|
+
result = `${item.name}${quantitySuffix} - ${item.memo}`;
|
|
756
291
|
}
|
|
757
|
-
if (quantitySuffix) {
|
|
758
|
-
|
|
292
|
+
else if (quantitySuffix) {
|
|
293
|
+
result = `${item.name}${quantitySuffix}`;
|
|
759
294
|
}
|
|
760
|
-
|
|
295
|
+
else {
|
|
296
|
+
result = item.name;
|
|
297
|
+
}
|
|
298
|
+
return truncateToLength(result, MAX_MEMO_LENGTH);
|
|
761
299
|
}
|
|
762
|
-
const BIG_TICKET_THRESHOLD_MILLIUNITS = 50000;
|
|
763
|
-
const COLLAPSE_THRESHOLD = 5;
|
|
764
|
-
const MAX_ITEMS_PER_MEMO = 5;
|
|
765
|
-
const MAX_MEMO_LENGTH = 150;
|
|
766
300
|
function applySmartCollapseLogic(categoryCalculations, taxMilliunits) {
|
|
767
301
|
const specialItems = [];
|
|
768
302
|
const remainingItemsByCategory = [];
|
|
@@ -891,6 +425,14 @@ function collapseItemsByCategory(categoryGroup) {
|
|
|
891
425
|
}
|
|
892
426
|
return subtransactions;
|
|
893
427
|
}
|
|
428
|
+
function truncateItemName(name, amountSuffix, maxLength) {
|
|
429
|
+
const ellipsis = '...';
|
|
430
|
+
const availableForName = maxLength - ellipsis.length - amountSuffix.length;
|
|
431
|
+
if (availableForName <= 0) {
|
|
432
|
+
return amountSuffix.substring(0, maxLength);
|
|
433
|
+
}
|
|
434
|
+
return name.substring(0, availableForName) + ellipsis + amountSuffix;
|
|
435
|
+
}
|
|
894
436
|
function buildCollapsedMemo(items) {
|
|
895
437
|
const parts = [];
|
|
896
438
|
let currentLength = 0;
|
|
@@ -899,8 +441,12 @@ function buildCollapsedMemo(items) {
|
|
|
899
441
|
if (!item)
|
|
900
442
|
continue;
|
|
901
443
|
const amount = milliunitsToAmount(item.amount_milliunits);
|
|
902
|
-
const
|
|
444
|
+
const amountSuffix = ` $${amount.toFixed(2)}`;
|
|
445
|
+
let itemStr = `${item.name}${amountSuffix}`;
|
|
903
446
|
const separator = i > 0 ? ', ' : '';
|
|
447
|
+
if (parts.length === 0 && itemStr.length > MAX_MEMO_LENGTH) {
|
|
448
|
+
itemStr = truncateItemName(item.name, amountSuffix, MAX_MEMO_LENGTH);
|
|
449
|
+
}
|
|
904
450
|
const testLength = currentLength + separator.length + itemStr.length;
|
|
905
451
|
if (parts.length > 0 && testLength + 4 > MAX_MEMO_LENGTH) {
|
|
906
452
|
break;
|
|
@@ -1522,54 +1068,6 @@ async function resolveMetadata(ynabAPI, budgetId, transactions, options = {}) {
|
|
|
1522
1068
|
await Promise.all(fetchPromises);
|
|
1523
1069
|
return { metadata, unresolvedIds: Array.from(metadataAwaitingResolution), previewDetails };
|
|
1524
1070
|
}
|
|
1525
|
-
function finalizeBulkUpdateResponse(response) {
|
|
1526
|
-
const appendMessage = (message, addition) => {
|
|
1527
|
-
if (!message) {
|
|
1528
|
-
return addition;
|
|
1529
|
-
}
|
|
1530
|
-
if (message.includes(addition)) {
|
|
1531
|
-
return message;
|
|
1532
|
-
}
|
|
1533
|
-
return `${message} ${addition}`;
|
|
1534
|
-
};
|
|
1535
|
-
const fullSize = estimatePayloadSize(response);
|
|
1536
|
-
if (fullSize <= FULL_RESPONSE_THRESHOLD) {
|
|
1537
|
-
return { ...response, mode: 'full' };
|
|
1538
|
-
}
|
|
1539
|
-
const { transactions, ...summaryResponse } = response;
|
|
1540
|
-
const summaryPayload = {
|
|
1541
|
-
...summaryResponse,
|
|
1542
|
-
message: appendMessage(response.message, 'Response downgraded to summary to stay under size limits.'),
|
|
1543
|
-
mode: 'summary',
|
|
1544
|
-
};
|
|
1545
|
-
if (estimatePayloadSize(summaryPayload) <= SUMMARY_RESPONSE_THRESHOLD) {
|
|
1546
|
-
return summaryPayload;
|
|
1547
|
-
}
|
|
1548
|
-
const idsOnlyPayload = {
|
|
1549
|
-
...summaryPayload,
|
|
1550
|
-
results: summaryResponse.results.map((result) => {
|
|
1551
|
-
const simplified = {
|
|
1552
|
-
request_index: result.request_index,
|
|
1553
|
-
status: result.status,
|
|
1554
|
-
transaction_id: result.transaction_id,
|
|
1555
|
-
correlation_key: result.correlation_key,
|
|
1556
|
-
};
|
|
1557
|
-
if (result.error) {
|
|
1558
|
-
simplified.error = result.error;
|
|
1559
|
-
}
|
|
1560
|
-
if (result.error_code) {
|
|
1561
|
-
simplified.error_code = result.error_code;
|
|
1562
|
-
}
|
|
1563
|
-
return simplified;
|
|
1564
|
-
}),
|
|
1565
|
-
message: appendMessage(summaryResponse.message, 'Response downgraded to ids_only to meet 100KB limit.'),
|
|
1566
|
-
mode: 'ids_only',
|
|
1567
|
-
};
|
|
1568
|
-
if (estimatePayloadSize(idsOnlyPayload) <= MAX_RESPONSE_BYTES) {
|
|
1569
|
-
return idsOnlyPayload;
|
|
1570
|
-
}
|
|
1571
|
-
throw new ValidationError('RESPONSE_TOO_LARGE: Unable to format bulk update response within 100KB limit', `Batch size: ${response.summary.total_requested} transactions`, ['Reduce the batch size and retry', 'Consider splitting into multiple smaller batches']);
|
|
1572
|
-
}
|
|
1573
1071
|
export async function handleUpdateTransactions(ynabAPI, deltaCacheOrParams, knowledgeStoreOrParams, maybeParams) {
|
|
1574
1072
|
const { deltaCache, knowledgeStore, params } = resolveDeltaWriteArgs(deltaCacheOrParams, knowledgeStoreOrParams, maybeParams);
|
|
1575
1073
|
return (await withToolErrorHandling(async () => {
|
|
@@ -1878,38 +1376,6 @@ export async function handleUpdateTransactions(ynabAPI, deltaCacheOrParams, know
|
|
|
1878
1376
|
};
|
|
1879
1377
|
}, 'ynab:update_transactions', 'bulk transaction update'));
|
|
1880
1378
|
}
|
|
1881
|
-
function handleTransactionError(error, defaultMessage) {
|
|
1882
|
-
let errorMessage = defaultMessage;
|
|
1883
|
-
if (error instanceof Error) {
|
|
1884
|
-
if (error.message.includes('401') || error.message.includes('Unauthorized')) {
|
|
1885
|
-
errorMessage = 'Invalid or expired YNAB access token';
|
|
1886
|
-
}
|
|
1887
|
-
else if (error.message.includes('403') || error.message.includes('Forbidden')) {
|
|
1888
|
-
errorMessage = 'Insufficient permissions to access YNAB data';
|
|
1889
|
-
}
|
|
1890
|
-
else if (error.message.includes('404') || error.message.includes('Not Found')) {
|
|
1891
|
-
errorMessage = 'Budget, account, category, or transaction not found';
|
|
1892
|
-
}
|
|
1893
|
-
else if (error.message.includes('429') || error.message.includes('Too Many Requests')) {
|
|
1894
|
-
errorMessage = 'Rate limit exceeded. Please try again later';
|
|
1895
|
-
}
|
|
1896
|
-
else if (error.message.includes('500') || error.message.includes('Internal Server Error')) {
|
|
1897
|
-
errorMessage = 'YNAB service is currently unavailable';
|
|
1898
|
-
}
|
|
1899
|
-
}
|
|
1900
|
-
return {
|
|
1901
|
-
content: [
|
|
1902
|
-
{
|
|
1903
|
-
type: 'text',
|
|
1904
|
-
text: responseFormatter.format({
|
|
1905
|
-
error: {
|
|
1906
|
-
message: errorMessage,
|
|
1907
|
-
},
|
|
1908
|
-
}),
|
|
1909
|
-
},
|
|
1910
|
-
],
|
|
1911
|
-
};
|
|
1912
|
-
}
|
|
1913
1379
|
export const registerTransactionTools = (registry, context) => {
|
|
1914
1380
|
const { adapt, adaptWithDelta, adaptWrite } = createAdapters(context);
|
|
1915
1381
|
const budgetResolver = createBudgetResolver(context);
|
|
@@ -2031,3 +1497,5 @@ export const registerTransactionTools = (registry, context) => {
|
|
|
2031
1497
|
},
|
|
2032
1498
|
});
|
|
2033
1499
|
};
|
|
1500
|
+
export { ListTransactionsSchema, GetTransactionSchema, CreateTransactionSchema, CreateTransactionsSchema, CreateReceiptSplitTransactionSchema, UpdateTransactionSchema, UpdateTransactionsSchema, DeleteTransactionSchema, } from './transactionSchemas.js';
|
|
1501
|
+
export { generateCorrelationKey, toCorrelationPayload, correlateResults, estimatePayloadSize, finalizeResponse, finalizeBulkUpdateResponse, handleTransactionError, toMonthKey, ensureTransaction, appendCategoryIds, collectCategoryIdsFromSources, setsEqual, invalidateTransactionCaches, } from './transactionUtils.js';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
import * as ynab from 'ynab';
|
|
3
|
+
import type { SaveTransactionsResponseData } from 'ynab/dist/models/SaveTransactionsResponseData.js';
|
|
4
|
+
import type { DeltaCache } from '../server/deltaCache.js';
|
|
5
|
+
import type { ServerKnowledgeStore } from '../server/serverKnowledgeStore.js';
|
|
6
|
+
import type { CategorySource, TransactionCacheInvalidationOptions, CorrelationPayload, CorrelationPayloadInput, BulkTransactionInput, BulkTransactionResult, BulkCreateResponse, BulkUpdateResponse } from './transactionSchemas.js';
|
|
7
|
+
export declare function ensureTransaction<T>(transaction: T | undefined, errorMessage: string): T;
|
|
8
|
+
export declare function appendCategoryIds(source: CategorySource | undefined, target: Set<string>): void;
|
|
9
|
+
export declare function collectCategoryIdsFromSources(...sources: (CategorySource | undefined)[]): Set<string>;
|
|
10
|
+
export declare function setsEqual<T>(a: Set<T>, b: Set<T>): boolean;
|
|
11
|
+
export declare const toMonthKey: (date: string) => string;
|
|
12
|
+
export declare function invalidateTransactionCaches(deltaCache: DeltaCache, knowledgeStore: ServerKnowledgeStore, budgetId: string, serverKnowledge: number | undefined, affectedAccountIds: Set<string>, affectedMonths: Set<string>, options?: TransactionCacheInvalidationOptions): void;
|
|
13
|
+
export declare function generateCorrelationKey(transaction: {
|
|
14
|
+
account_id?: string;
|
|
15
|
+
date?: string;
|
|
16
|
+
amount?: number;
|
|
17
|
+
payee_id?: string | null;
|
|
18
|
+
payee_name?: string | null;
|
|
19
|
+
category_id?: string | null;
|
|
20
|
+
memo?: string | null;
|
|
21
|
+
cleared?: ynab.TransactionClearedStatus;
|
|
22
|
+
approved?: boolean;
|
|
23
|
+
flag_color?: ynab.TransactionFlagColor | null;
|
|
24
|
+
import_id?: string | null;
|
|
25
|
+
}): string;
|
|
26
|
+
export declare function toCorrelationPayload(transaction: CorrelationPayloadInput): CorrelationPayload;
|
|
27
|
+
export declare function correlateResults(requests: BulkTransactionInput[], responseData: SaveTransactionsResponseData, duplicateImportIds: Set<string>): BulkTransactionResult[];
|
|
28
|
+
export declare function estimatePayloadSize(payload: BulkCreateResponse | BulkUpdateResponse): number;
|
|
29
|
+
export declare function finalizeResponse(response: BulkCreateResponse): BulkCreateResponse;
|
|
30
|
+
export declare function finalizeBulkUpdateResponse(response: BulkUpdateResponse): BulkUpdateResponse;
|
|
31
|
+
export declare function handleTransactionError(error: unknown, defaultMessage: string): CallToolResult;
|