@dizzlkheinz/ynab-mcpb 0.18.3 → 0.18.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -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 +4 -559
- 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__/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 +102 -823
- 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 () => {
|
|
@@ -1545,54 +1068,6 @@ async function resolveMetadata(ynabAPI, budgetId, transactions, options = {}) {
|
|
|
1545
1068
|
await Promise.all(fetchPromises);
|
|
1546
1069
|
return { metadata, unresolvedIds: Array.from(metadataAwaitingResolution), previewDetails };
|
|
1547
1070
|
}
|
|
1548
|
-
function finalizeBulkUpdateResponse(response) {
|
|
1549
|
-
const appendMessage = (message, addition) => {
|
|
1550
|
-
if (!message) {
|
|
1551
|
-
return addition;
|
|
1552
|
-
}
|
|
1553
|
-
if (message.includes(addition)) {
|
|
1554
|
-
return message;
|
|
1555
|
-
}
|
|
1556
|
-
return `${message} ${addition}`;
|
|
1557
|
-
};
|
|
1558
|
-
const fullSize = estimatePayloadSize(response);
|
|
1559
|
-
if (fullSize <= FULL_RESPONSE_THRESHOLD) {
|
|
1560
|
-
return { ...response, mode: 'full' };
|
|
1561
|
-
}
|
|
1562
|
-
const { transactions, ...summaryResponse } = response;
|
|
1563
|
-
const summaryPayload = {
|
|
1564
|
-
...summaryResponse,
|
|
1565
|
-
message: appendMessage(response.message, 'Response downgraded to summary to stay under size limits.'),
|
|
1566
|
-
mode: 'summary',
|
|
1567
|
-
};
|
|
1568
|
-
if (estimatePayloadSize(summaryPayload) <= SUMMARY_RESPONSE_THRESHOLD) {
|
|
1569
|
-
return summaryPayload;
|
|
1570
|
-
}
|
|
1571
|
-
const idsOnlyPayload = {
|
|
1572
|
-
...summaryPayload,
|
|
1573
|
-
results: summaryResponse.results.map((result) => {
|
|
1574
|
-
const simplified = {
|
|
1575
|
-
request_index: result.request_index,
|
|
1576
|
-
status: result.status,
|
|
1577
|
-
transaction_id: result.transaction_id,
|
|
1578
|
-
correlation_key: result.correlation_key,
|
|
1579
|
-
};
|
|
1580
|
-
if (result.error) {
|
|
1581
|
-
simplified.error = result.error;
|
|
1582
|
-
}
|
|
1583
|
-
if (result.error_code) {
|
|
1584
|
-
simplified.error_code = result.error_code;
|
|
1585
|
-
}
|
|
1586
|
-
return simplified;
|
|
1587
|
-
}),
|
|
1588
|
-
message: appendMessage(summaryResponse.message, 'Response downgraded to ids_only to meet 100KB limit.'),
|
|
1589
|
-
mode: 'ids_only',
|
|
1590
|
-
};
|
|
1591
|
-
if (estimatePayloadSize(idsOnlyPayload) <= MAX_RESPONSE_BYTES) {
|
|
1592
|
-
return idsOnlyPayload;
|
|
1593
|
-
}
|
|
1594
|
-
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']);
|
|
1595
|
-
}
|
|
1596
1071
|
export async function handleUpdateTransactions(ynabAPI, deltaCacheOrParams, knowledgeStoreOrParams, maybeParams) {
|
|
1597
1072
|
const { deltaCache, knowledgeStore, params } = resolveDeltaWriteArgs(deltaCacheOrParams, knowledgeStoreOrParams, maybeParams);
|
|
1598
1073
|
return (await withToolErrorHandling(async () => {
|
|
@@ -1901,38 +1376,6 @@ export async function handleUpdateTransactions(ynabAPI, deltaCacheOrParams, know
|
|
|
1901
1376
|
};
|
|
1902
1377
|
}, 'ynab:update_transactions', 'bulk transaction update'));
|
|
1903
1378
|
}
|
|
1904
|
-
function handleTransactionError(error, defaultMessage) {
|
|
1905
|
-
let errorMessage = defaultMessage;
|
|
1906
|
-
if (error instanceof Error) {
|
|
1907
|
-
if (error.message.includes('401') || error.message.includes('Unauthorized')) {
|
|
1908
|
-
errorMessage = 'Invalid or expired YNAB access token';
|
|
1909
|
-
}
|
|
1910
|
-
else if (error.message.includes('403') || error.message.includes('Forbidden')) {
|
|
1911
|
-
errorMessage = 'Insufficient permissions to access YNAB data';
|
|
1912
|
-
}
|
|
1913
|
-
else if (error.message.includes('404') || error.message.includes('Not Found')) {
|
|
1914
|
-
errorMessage = 'Budget, account, category, or transaction not found';
|
|
1915
|
-
}
|
|
1916
|
-
else if (error.message.includes('429') || error.message.includes('Too Many Requests')) {
|
|
1917
|
-
errorMessage = 'Rate limit exceeded. Please try again later';
|
|
1918
|
-
}
|
|
1919
|
-
else if (error.message.includes('500') || error.message.includes('Internal Server Error')) {
|
|
1920
|
-
errorMessage = 'YNAB service is currently unavailable';
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
return {
|
|
1924
|
-
content: [
|
|
1925
|
-
{
|
|
1926
|
-
type: 'text',
|
|
1927
|
-
text: responseFormatter.format({
|
|
1928
|
-
error: {
|
|
1929
|
-
message: errorMessage,
|
|
1930
|
-
},
|
|
1931
|
-
}),
|
|
1932
|
-
},
|
|
1933
|
-
],
|
|
1934
|
-
};
|
|
1935
|
-
}
|
|
1936
1379
|
export const registerTransactionTools = (registry, context) => {
|
|
1937
1380
|
const { adapt, adaptWithDelta, adaptWrite } = createAdapters(context);
|
|
1938
1381
|
const budgetResolver = createBudgetResolver(context);
|
|
@@ -2054,3 +1497,5 @@ export const registerTransactionTools = (registry, context) => {
|
|
|
2054
1497
|
},
|
|
2055
1498
|
});
|
|
2056
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;
|