@dizzlkheinz/ynab-mcpb 0.17.1 → 0.18.1
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/.github/workflows/ci-tests.yml +4 -4
- package/.github/workflows/full-integration.yml +2 -2
- package/.github/workflows/publish.yml +1 -1
- package/.github/workflows/release.yml +2 -2
- package/CHANGELOG.md +12 -1
- package/CLAUDE.md +10 -7
- package/README.md +6 -1
- package/dist/bundle/index.cjs +52 -52
- package/dist/server/YNABMCPServer.d.ts +7 -2
- package/dist/server/YNABMCPServer.js +42 -11
- package/dist/server/cacheManager.js +6 -5
- package/dist/server/completions.d.ts +25 -0
- package/dist/server/completions.js +160 -0
- package/dist/server/config.d.ts +2 -2
- package/dist/server/errorHandler.js +1 -0
- package/dist/server/rateLimiter.js +3 -1
- package/dist/server/resources.d.ts +1 -0
- package/dist/server/resources.js +33 -16
- package/dist/server/securityMiddleware.d.ts +2 -1
- package/dist/server/securityMiddleware.js +1 -0
- package/dist/server/toolRegistry.d.ts +9 -0
- package/dist/server/toolRegistry.js +11 -0
- package/dist/tools/adapters.d.ts +3 -1
- package/dist/tools/adapters.js +1 -0
- package/dist/tools/reconciliation/executor.d.ts +2 -0
- package/dist/tools/reconciliation/executor.js +26 -9
- package/dist/tools/reconciliation/index.d.ts +3 -2
- package/dist/tools/reconciliation/index.js +4 -3
- package/docs/reference/API.md +68 -27
- package/package.json +2 -2
- package/src/__tests__/comprehensive.integration.test.ts +4 -4
- package/src/__tests__/performance.test.ts +1 -2
- package/src/__tests__/smoke.e2e.test.ts +70 -0
- package/src/__tests__/testUtils.ts +2 -113
- package/src/server/YNABMCPServer.ts +64 -10
- package/src/server/__tests__/completions.integration.test.ts +117 -0
- package/src/server/__tests__/completions.test.ts +319 -0
- package/src/server/__tests__/resources.template.test.ts +3 -3
- package/src/server/__tests__/resources.test.ts +3 -3
- package/src/server/__tests__/toolRegistration.test.ts +1 -1
- package/src/server/cacheManager.ts +7 -6
- package/src/server/completions.ts +279 -0
- package/src/server/errorHandler.ts +1 -0
- package/src/server/rateLimiter.ts +4 -1
- package/src/server/resources.ts +49 -13
- package/src/server/securityMiddleware.ts +1 -0
- package/src/server/toolRegistry.ts +42 -0
- package/src/tools/adapters.ts +22 -1
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +12 -26
- package/src/tools/reconciliation/__tests__/executor.progress.test.ts +462 -0
- package/src/tools/reconciliation/__tests__/executor.test.ts +36 -31
- package/src/tools/reconciliation/executor.ts +56 -27
- package/src/tools/reconciliation/index.ts +7 -3
- package/vitest.config.ts +2 -0
- package/src/__tests__/delta.performance.test.ts +0 -80
- package/src/__tests__/workflows.e2e.test.ts +0 -1658
|
@@ -0,0 +1,462 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import type * as ynab from 'ynab';
|
|
3
|
+
import type { ProgressCallback } from '../../../server/toolRegistry.js';
|
|
4
|
+
import type { ReconciliationAnalysis, TransactionMatch, BankTransaction } from '../types.js';
|
|
5
|
+
import type { ReconcileAccountRequest } from '../index.js';
|
|
6
|
+
import { executeReconciliation, type ExecutionOptions } from '../executor.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Unit tests for progress notification functionality in reconciliation executor
|
|
10
|
+
*/
|
|
11
|
+
describe('Reconciliation Progress Notifications', () => {
|
|
12
|
+
let mockYnabAPI: Partial<ynab.API>;
|
|
13
|
+
let mockProgressCallback: ProgressCallback;
|
|
14
|
+
let progressCalls: { progress: number; total?: number; message?: string }[];
|
|
15
|
+
|
|
16
|
+
const createMockTransaction = (
|
|
17
|
+
overrides: Partial<ynab.TransactionDetail> = {},
|
|
18
|
+
): ynab.TransactionDetail => ({
|
|
19
|
+
id: 'txn-1',
|
|
20
|
+
date: '2024-01-15',
|
|
21
|
+
amount: -25000,
|
|
22
|
+
memo: 'Test transaction',
|
|
23
|
+
cleared: 'uncleared' as ynab.TransactionClearedStatus,
|
|
24
|
+
approved: true,
|
|
25
|
+
flag_color: null,
|
|
26
|
+
flag_name: null,
|
|
27
|
+
account_id: 'acc-1',
|
|
28
|
+
account_name: 'Test Account',
|
|
29
|
+
payee_id: 'payee-1',
|
|
30
|
+
payee_name: 'Test Payee',
|
|
31
|
+
category_id: 'cat-1',
|
|
32
|
+
category_name: 'Test Category',
|
|
33
|
+
transfer_account_id: null,
|
|
34
|
+
transfer_transaction_id: null,
|
|
35
|
+
matched_transaction_id: null,
|
|
36
|
+
import_id: null,
|
|
37
|
+
import_payee_name: null,
|
|
38
|
+
import_payee_name_original: null,
|
|
39
|
+
debt_transaction_type: null,
|
|
40
|
+
deleted: false,
|
|
41
|
+
subtransactions: [],
|
|
42
|
+
...overrides,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const createBankTransaction = (overrides: Partial<BankTransaction> = {}): BankTransaction => ({
|
|
46
|
+
date: '2024-01-15',
|
|
47
|
+
amount: -25000,
|
|
48
|
+
payee: 'Test Payee',
|
|
49
|
+
memo: 'Bank memo',
|
|
50
|
+
...overrides,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const createMatch = (
|
|
54
|
+
bankTxn: BankTransaction,
|
|
55
|
+
ynabTxn: ynab.TransactionDetail,
|
|
56
|
+
score = 0.95,
|
|
57
|
+
): TransactionMatch => ({
|
|
58
|
+
bankTransaction: bankTxn,
|
|
59
|
+
ynabTransaction: ynabTxn,
|
|
60
|
+
score,
|
|
61
|
+
matchType: 'exact',
|
|
62
|
+
matchDetails: {
|
|
63
|
+
amount_match: true,
|
|
64
|
+
date_match: true,
|
|
65
|
+
payee_similarity: 1.0,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
beforeEach(() => {
|
|
70
|
+
progressCalls = [];
|
|
71
|
+
mockProgressCallback = vi.fn().mockImplementation(async (params) => {
|
|
72
|
+
progressCalls.push(params);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
mockYnabAPI = {
|
|
76
|
+
transactions: {
|
|
77
|
+
createTransaction: vi.fn().mockResolvedValue({
|
|
78
|
+
data: { transaction: createMockTransaction() },
|
|
79
|
+
}),
|
|
80
|
+
createTransactions: vi.fn().mockResolvedValue({
|
|
81
|
+
data: {
|
|
82
|
+
transactions: [createMockTransaction()],
|
|
83
|
+
duplicate_import_ids: [],
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
updateTransactions: vi.fn().mockResolvedValue({
|
|
87
|
+
data: { transactions: [createMockTransaction({ cleared: 'cleared' })] },
|
|
88
|
+
}),
|
|
89
|
+
} as unknown as ynab.TransactionsApi,
|
|
90
|
+
accounts: {
|
|
91
|
+
getAccountById: vi.fn().mockResolvedValue({
|
|
92
|
+
data: {
|
|
93
|
+
account: {
|
|
94
|
+
balance: 100000,
|
|
95
|
+
cleared_balance: 100000,
|
|
96
|
+
uncleared_balance: 0,
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
}),
|
|
100
|
+
} as unknown as ynab.AccountsApi,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('progress callback invocation', () => {
|
|
105
|
+
it('should call progress callback during bulk transaction creation', async () => {
|
|
106
|
+
const unmatchedBank: BankTransaction[] = [
|
|
107
|
+
createBankTransaction({ payee: 'Payee 1', amount: -10000 }),
|
|
108
|
+
createBankTransaction({ payee: 'Payee 2', amount: -20000 }),
|
|
109
|
+
createBankTransaction({ payee: 'Payee 3', amount: -30000 }),
|
|
110
|
+
];
|
|
111
|
+
|
|
112
|
+
const analysis: ReconciliationAnalysis = {
|
|
113
|
+
summary: {
|
|
114
|
+
bank_transactions_count: 3,
|
|
115
|
+
ynab_transactions_count: 0,
|
|
116
|
+
matches: 0,
|
|
117
|
+
unmatched_bank: 3,
|
|
118
|
+
unmatched_ynab: 0,
|
|
119
|
+
match_rate: 0,
|
|
120
|
+
statement_date_range: '2024-01-01 to 2024-01-31',
|
|
121
|
+
},
|
|
122
|
+
auto_matches: [],
|
|
123
|
+
unmatched_bank: unmatchedBank,
|
|
124
|
+
unmatched_ynab: [],
|
|
125
|
+
balance_info: {
|
|
126
|
+
current_cleared: 100000,
|
|
127
|
+
target_statement: 40000,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const params: ReconcileAccountRequest = {
|
|
132
|
+
account_id: 'acc-1',
|
|
133
|
+
csv_data: 'date,amount,payee\n2024-01-15,-10,Payee 1',
|
|
134
|
+
dry_run: false,
|
|
135
|
+
auto_create_transactions: true,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const options: ExecutionOptions = {
|
|
139
|
+
ynabAPI: mockYnabAPI as ynab.API,
|
|
140
|
+
analysis,
|
|
141
|
+
params,
|
|
142
|
+
budgetId: 'budget-1',
|
|
143
|
+
accountId: 'acc-1',
|
|
144
|
+
initialAccount: { balance: 100000, cleared_balance: 100000, uncleared_balance: 0 },
|
|
145
|
+
currencyCode: 'USD',
|
|
146
|
+
sendProgress: mockProgressCallback,
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
await executeReconciliation(options);
|
|
150
|
+
|
|
151
|
+
expect(mockProgressCallback).toHaveBeenCalled();
|
|
152
|
+
expect(progressCalls.length).toBeGreaterThan(0);
|
|
153
|
+
|
|
154
|
+
// Verify progress structure
|
|
155
|
+
for (const call of progressCalls) {
|
|
156
|
+
expect(call).toHaveProperty('progress');
|
|
157
|
+
expect(call).toHaveProperty('total');
|
|
158
|
+
expect(call).toHaveProperty('message');
|
|
159
|
+
expect(typeof call.progress).toBe('number');
|
|
160
|
+
expect(typeof call.total).toBe('number');
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should report progress during sequential fallback', async () => {
|
|
165
|
+
const unmatchedBank: BankTransaction[] = [
|
|
166
|
+
createBankTransaction({ payee: 'Single Payee', amount: -10000 }),
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
const analysis: ReconciliationAnalysis = {
|
|
170
|
+
summary: {
|
|
171
|
+
bank_transactions_count: 1,
|
|
172
|
+
ynab_transactions_count: 0,
|
|
173
|
+
matches: 0,
|
|
174
|
+
unmatched_bank: 1,
|
|
175
|
+
unmatched_ynab: 0,
|
|
176
|
+
match_rate: 0,
|
|
177
|
+
statement_date_range: '2024-01-01 to 2024-01-31',
|
|
178
|
+
},
|
|
179
|
+
auto_matches: [],
|
|
180
|
+
unmatched_bank: unmatchedBank,
|
|
181
|
+
unmatched_ynab: [],
|
|
182
|
+
balance_info: {
|
|
183
|
+
current_cleared: 100000,
|
|
184
|
+
target_statement: 90000,
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const params: ReconcileAccountRequest = {
|
|
189
|
+
account_id: 'acc-1',
|
|
190
|
+
csv_data: 'date,amount,payee\n2024-01-15,-10,Single Payee',
|
|
191
|
+
dry_run: false,
|
|
192
|
+
auto_create_transactions: true,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const options: ExecutionOptions = {
|
|
196
|
+
ynabAPI: mockYnabAPI as ynab.API,
|
|
197
|
+
analysis,
|
|
198
|
+
params,
|
|
199
|
+
budgetId: 'budget-1',
|
|
200
|
+
accountId: 'acc-1',
|
|
201
|
+
initialAccount: { balance: 100000, cleared_balance: 100000, uncleared_balance: 0 },
|
|
202
|
+
currencyCode: 'USD',
|
|
203
|
+
sendProgress: mockProgressCallback,
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
await executeReconciliation(options);
|
|
207
|
+
|
|
208
|
+
// With only 1 transaction, it goes through sequential path
|
|
209
|
+
expect(mockProgressCallback).toHaveBeenCalled();
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('should not call progress callback when not provided', async () => {
|
|
213
|
+
const analysis: ReconciliationAnalysis = {
|
|
214
|
+
summary: {
|
|
215
|
+
bank_transactions_count: 1,
|
|
216
|
+
ynab_transactions_count: 0,
|
|
217
|
+
matches: 0,
|
|
218
|
+
unmatched_bank: 1,
|
|
219
|
+
unmatched_ynab: 0,
|
|
220
|
+
match_rate: 0,
|
|
221
|
+
statement_date_range: '2024-01-01 to 2024-01-31',
|
|
222
|
+
},
|
|
223
|
+
auto_matches: [],
|
|
224
|
+
unmatched_bank: [createBankTransaction()],
|
|
225
|
+
unmatched_ynab: [],
|
|
226
|
+
balance_info: {
|
|
227
|
+
current_cleared: 100000,
|
|
228
|
+
target_statement: 75000,
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const params: ReconcileAccountRequest = {
|
|
233
|
+
account_id: 'acc-1',
|
|
234
|
+
csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
|
|
235
|
+
dry_run: false,
|
|
236
|
+
auto_create_transactions: true,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
const options: ExecutionOptions = {
|
|
240
|
+
ynabAPI: mockYnabAPI as ynab.API,
|
|
241
|
+
analysis,
|
|
242
|
+
params,
|
|
243
|
+
budgetId: 'budget-1',
|
|
244
|
+
accountId: 'acc-1',
|
|
245
|
+
initialAccount: { balance: 100000, cleared_balance: 100000, uncleared_balance: 0 },
|
|
246
|
+
currencyCode: 'USD',
|
|
247
|
+
// No sendProgress callback
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
// Should not throw when no callback provided
|
|
251
|
+
await expect(executeReconciliation(options)).resolves.toBeDefined();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('should report progress during transaction updates', async () => {
|
|
255
|
+
const ynabTxn = createMockTransaction({ cleared: 'uncleared' });
|
|
256
|
+
const bankTxn = createBankTransaction();
|
|
257
|
+
const match = createMatch(bankTxn, ynabTxn);
|
|
258
|
+
|
|
259
|
+
const analysis: ReconciliationAnalysis = {
|
|
260
|
+
summary: {
|
|
261
|
+
bank_transactions_count: 1,
|
|
262
|
+
ynab_transactions_count: 1,
|
|
263
|
+
matches: 1,
|
|
264
|
+
unmatched_bank: 0,
|
|
265
|
+
unmatched_ynab: 0,
|
|
266
|
+
match_rate: 1,
|
|
267
|
+
statement_date_range: '2024-01-01 to 2024-01-31',
|
|
268
|
+
},
|
|
269
|
+
auto_matches: [match],
|
|
270
|
+
unmatched_bank: [],
|
|
271
|
+
unmatched_ynab: [],
|
|
272
|
+
balance_info: {
|
|
273
|
+
current_cleared: 75000,
|
|
274
|
+
target_statement: 100000,
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const params: ReconcileAccountRequest = {
|
|
279
|
+
account_id: 'acc-1',
|
|
280
|
+
csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
|
|
281
|
+
dry_run: false,
|
|
282
|
+
auto_update_cleared_status: true,
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const options: ExecutionOptions = {
|
|
286
|
+
ynabAPI: mockYnabAPI as ynab.API,
|
|
287
|
+
analysis,
|
|
288
|
+
params,
|
|
289
|
+
budgetId: 'budget-1',
|
|
290
|
+
accountId: 'acc-1',
|
|
291
|
+
initialAccount: { balance: 100000, cleared_balance: 75000, uncleared_balance: 25000 },
|
|
292
|
+
currencyCode: 'USD',
|
|
293
|
+
sendProgress: mockProgressCallback,
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
await executeReconciliation(options);
|
|
297
|
+
|
|
298
|
+
expect(mockProgressCallback).toHaveBeenCalled();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
describe('progress calculation accuracy', () => {
|
|
303
|
+
it('should exclude skipped matches from total count', async () => {
|
|
304
|
+
// Create a match that won't need updating (already cleared, same date)
|
|
305
|
+
const clearedTxn = createMockTransaction({ cleared: 'cleared' });
|
|
306
|
+
const bankTxn = createBankTransaction();
|
|
307
|
+
const matchNoUpdate = createMatch(bankTxn, clearedTxn);
|
|
308
|
+
|
|
309
|
+
// Create a match that will need updating
|
|
310
|
+
const unclearedTxn = createMockTransaction({ cleared: 'uncleared', id: 'txn-2' });
|
|
311
|
+
const bankTxn2 = createBankTransaction({ payee: 'Payee 2' });
|
|
312
|
+
const matchNeedsUpdate = createMatch(bankTxn2, unclearedTxn);
|
|
313
|
+
|
|
314
|
+
const analysis: ReconciliationAnalysis = {
|
|
315
|
+
summary: {
|
|
316
|
+
bank_transactions_count: 2,
|
|
317
|
+
ynab_transactions_count: 2,
|
|
318
|
+
matches: 2,
|
|
319
|
+
unmatched_bank: 0,
|
|
320
|
+
unmatched_ynab: 0,
|
|
321
|
+
match_rate: 1,
|
|
322
|
+
statement_date_range: '2024-01-01 to 2024-01-31',
|
|
323
|
+
},
|
|
324
|
+
auto_matches: [matchNoUpdate, matchNeedsUpdate],
|
|
325
|
+
unmatched_bank: [],
|
|
326
|
+
unmatched_ynab: [],
|
|
327
|
+
balance_info: {
|
|
328
|
+
current_cleared: 75000,
|
|
329
|
+
target_statement: 100000,
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const params: ReconcileAccountRequest = {
|
|
334
|
+
account_id: 'acc-1',
|
|
335
|
+
csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
|
|
336
|
+
dry_run: false,
|
|
337
|
+
auto_update_cleared_status: true,
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
const options: ExecutionOptions = {
|
|
341
|
+
ynabAPI: mockYnabAPI as ynab.API,
|
|
342
|
+
analysis,
|
|
343
|
+
params,
|
|
344
|
+
budgetId: 'budget-1',
|
|
345
|
+
accountId: 'acc-1',
|
|
346
|
+
initialAccount: { balance: 100000, cleared_balance: 75000, uncleared_balance: 25000 },
|
|
347
|
+
currencyCode: 'USD',
|
|
348
|
+
sendProgress: mockProgressCallback,
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
await executeReconciliation(options);
|
|
352
|
+
|
|
353
|
+
// The total should only count the match that needs updating (1), not both (2)
|
|
354
|
+
if (progressCalls.length > 0) {
|
|
355
|
+
const lastCall = progressCalls[progressCalls.length - 1]!;
|
|
356
|
+
// Total should be 1 (only the uncleared transaction needs updating)
|
|
357
|
+
expect(lastCall.total).toBe(1);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('should include all operation types in total count', async () => {
|
|
362
|
+
const unmatchedBank = [createBankTransaction({ payee: 'New Payee' })];
|
|
363
|
+
const unclearedTxn = createMockTransaction({ cleared: 'uncleared' });
|
|
364
|
+
const matchNeedsUpdate = createMatch(createBankTransaction(), unclearedTxn);
|
|
365
|
+
const unmatchedYnab = [createMockTransaction({ id: 'unmatched-ynab', cleared: 'cleared' })];
|
|
366
|
+
|
|
367
|
+
const analysis: ReconciliationAnalysis = {
|
|
368
|
+
summary: {
|
|
369
|
+
bank_transactions_count: 2,
|
|
370
|
+
ynab_transactions_count: 2,
|
|
371
|
+
matches: 1,
|
|
372
|
+
unmatched_bank: 1,
|
|
373
|
+
unmatched_ynab: 1,
|
|
374
|
+
match_rate: 0.5,
|
|
375
|
+
statement_date_range: '2024-01-01 to 2024-01-31',
|
|
376
|
+
},
|
|
377
|
+
auto_matches: [matchNeedsUpdate],
|
|
378
|
+
unmatched_bank: unmatchedBank,
|
|
379
|
+
unmatched_ynab: unmatchedYnab,
|
|
380
|
+
balance_info: {
|
|
381
|
+
current_cleared: 75000,
|
|
382
|
+
target_statement: 50000,
|
|
383
|
+
},
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
const params: ReconcileAccountRequest = {
|
|
387
|
+
account_id: 'acc-1',
|
|
388
|
+
csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
|
|
389
|
+
dry_run: false,
|
|
390
|
+
auto_create_transactions: true,
|
|
391
|
+
auto_update_cleared_status: true,
|
|
392
|
+
auto_unclear_missing: true,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
const options: ExecutionOptions = {
|
|
396
|
+
ynabAPI: mockYnabAPI as ynab.API,
|
|
397
|
+
analysis,
|
|
398
|
+
params,
|
|
399
|
+
budgetId: 'budget-1',
|
|
400
|
+
accountId: 'acc-1',
|
|
401
|
+
initialAccount: { balance: 100000, cleared_balance: 75000, uncleared_balance: 25000 },
|
|
402
|
+
currencyCode: 'USD',
|
|
403
|
+
sendProgress: mockProgressCallback,
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
await executeReconciliation(options);
|
|
407
|
+
|
|
408
|
+
// Total should be: 1 (create) + 1 (update match) + 1 (unclear) = 3
|
|
409
|
+
if (progressCalls.length > 0) {
|
|
410
|
+
const anyCall = progressCalls.find((c) => c.total !== undefined);
|
|
411
|
+
expect(anyCall?.total).toBe(3);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
describe('dry run behavior', () => {
|
|
417
|
+
it('should not call progress callback during dry run', async () => {
|
|
418
|
+
const analysis: ReconciliationAnalysis = {
|
|
419
|
+
summary: {
|
|
420
|
+
bank_transactions_count: 1,
|
|
421
|
+
ynab_transactions_count: 0,
|
|
422
|
+
matches: 0,
|
|
423
|
+
unmatched_bank: 1,
|
|
424
|
+
unmatched_ynab: 0,
|
|
425
|
+
match_rate: 0,
|
|
426
|
+
statement_date_range: '2024-01-01 to 2024-01-31',
|
|
427
|
+
},
|
|
428
|
+
auto_matches: [],
|
|
429
|
+
unmatched_bank: [createBankTransaction()],
|
|
430
|
+
unmatched_ynab: [],
|
|
431
|
+
balance_info: {
|
|
432
|
+
current_cleared: 100000,
|
|
433
|
+
target_statement: 75000,
|
|
434
|
+
},
|
|
435
|
+
};
|
|
436
|
+
|
|
437
|
+
const params: ReconcileAccountRequest = {
|
|
438
|
+
account_id: 'acc-1',
|
|
439
|
+
csv_data: 'date,amount,payee\n2024-01-15,-25,Test',
|
|
440
|
+
dry_run: true, // Dry run mode
|
|
441
|
+
auto_create_transactions: true,
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const options: ExecutionOptions = {
|
|
445
|
+
ynabAPI: mockYnabAPI as ynab.API,
|
|
446
|
+
analysis,
|
|
447
|
+
params,
|
|
448
|
+
budgetId: 'budget-1',
|
|
449
|
+
accountId: 'acc-1',
|
|
450
|
+
initialAccount: { balance: 100000, cleared_balance: 100000, uncleared_balance: 0 },
|
|
451
|
+
currencyCode: 'USD',
|
|
452
|
+
sendProgress: mockProgressCallback,
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
await executeReconciliation(options);
|
|
456
|
+
|
|
457
|
+
// In dry run, no actual operations happen, so no progress should be reported
|
|
458
|
+
// (Progress is only reported after successful API calls)
|
|
459
|
+
expect(mockYnabAPI.transactions?.createTransaction).not.toHaveBeenCalled();
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
});
|
|
@@ -484,9 +484,10 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
484
484
|
account_id: txn.account_id,
|
|
485
485
|
amount: txn.amount,
|
|
486
486
|
date: txn.date,
|
|
487
|
+
payee_name: txn.payee_name,
|
|
488
|
+
memo: txn.memo,
|
|
487
489
|
cleared: 'cleared',
|
|
488
490
|
approved: true,
|
|
489
|
-
import_id: txn.import_id, // Include import_id for correlation
|
|
490
491
|
}));
|
|
491
492
|
return { data: { transactions, duplicate_import_ids: [] } };
|
|
492
493
|
});
|
|
@@ -638,11 +639,12 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
638
639
|
account_id: txn.account_id,
|
|
639
640
|
amount: txn.amount,
|
|
640
641
|
date: txn.date,
|
|
642
|
+
payee_name: txn.payee_name,
|
|
643
|
+
memo: txn.memo,
|
|
641
644
|
cleared: 'cleared',
|
|
642
645
|
approved: true,
|
|
643
|
-
import_id: txn.import_id, // Include import_id for correlation
|
|
644
646
|
}));
|
|
645
|
-
return { data: { transactions } };
|
|
647
|
+
return { data: { transactions, duplicate_import_ids: [] } };
|
|
646
648
|
});
|
|
647
649
|
|
|
648
650
|
const result = await executeReconciliation({
|
|
@@ -690,33 +692,35 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
690
692
|
).rejects.toMatchObject({ status: 404 });
|
|
691
693
|
});
|
|
692
694
|
|
|
693
|
-
it('
|
|
694
|
-
|
|
695
|
+
it('creates all transactions without import_id (no YNAB-side duplicate detection)', async () => {
|
|
696
|
+
// Note: import_id is intentionally omitted from reconciliation-created transactions
|
|
697
|
+
// so they can match with bank-imported transactions. This means YNAB won't detect
|
|
698
|
+
// duplicates via import_id - the reconciliation matcher is responsible for that.
|
|
699
|
+
// Use amount of 100 to avoid early balance halting (tolerance is 10 milliunits)
|
|
700
|
+
const analysis = buildBulkAnalysis(3, 100);
|
|
695
701
|
const params = buildBulkParams(analysis.summary.target_statement_balance);
|
|
696
702
|
const initialAccount = { ...defaultAccountSnapshot };
|
|
697
703
|
const { api, mocks } = createMockYnabAPI(initialAccount);
|
|
698
704
|
|
|
699
705
|
mocks.createTransactions.mockImplementation(async (_budgetId, body: any) => {
|
|
700
|
-
const transactions = (body.transactions ?? []).map((txn: any, index: number) => {
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
}
|
|
714
|
-
const filtered = transactions.filter(Boolean);
|
|
715
|
-
const duplicateImportId = body.transactions?.[1]?.import_id;
|
|
706
|
+
const transactions = (body.transactions ?? []).map((txn: any, index: number) => ({
|
|
707
|
+
id: `created-${index}`,
|
|
708
|
+
account_id: txn.account_id,
|
|
709
|
+
amount: txn.amount,
|
|
710
|
+
date: txn.date,
|
|
711
|
+
payee_name: txn.payee_name,
|
|
712
|
+
memo: txn.memo,
|
|
713
|
+
cleared: 'cleared',
|
|
714
|
+
approved: true,
|
|
715
|
+
}));
|
|
716
|
+
// Verify no import_id is being sent
|
|
717
|
+
for (const txn of body.transactions ?? []) {
|
|
718
|
+
expect(txn.import_id).toBeUndefined();
|
|
719
|
+
}
|
|
716
720
|
return {
|
|
717
721
|
data: {
|
|
718
|
-
transactions
|
|
719
|
-
duplicate_import_ids:
|
|
722
|
+
transactions,
|
|
723
|
+
duplicate_import_ids: [],
|
|
720
724
|
},
|
|
721
725
|
};
|
|
722
726
|
});
|
|
@@ -731,10 +735,9 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
731
735
|
currencyCode: 'USD',
|
|
732
736
|
});
|
|
733
737
|
|
|
734
|
-
|
|
735
|
-
expect(
|
|
736
|
-
expect(result.
|
|
737
|
-
expect(result.summary.transactions_created).toBe(2);
|
|
738
|
+
// Without import_id, YNAB creates all transactions (no duplicate detection)
|
|
739
|
+
expect(result.bulk_operation_details?.duplicates_detected).toBe(0);
|
|
740
|
+
expect(result.summary.transactions_created).toBe(3);
|
|
738
741
|
});
|
|
739
742
|
|
|
740
743
|
it('honors halting logic when balance aligns mid-batch', async () => {
|
|
@@ -749,11 +752,12 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
749
752
|
account_id: txn.account_id,
|
|
750
753
|
amount: txn.amount,
|
|
751
754
|
date: txn.date,
|
|
755
|
+
payee_name: txn.payee_name,
|
|
756
|
+
memo: txn.memo,
|
|
752
757
|
cleared: 'cleared',
|
|
753
758
|
approved: true,
|
|
754
|
-
import_id: txn.import_id, // Include import_id for correlation
|
|
755
759
|
}));
|
|
756
|
-
return { data: { transactions } };
|
|
760
|
+
return { data: { transactions, duplicate_import_ids: [] } };
|
|
757
761
|
});
|
|
758
762
|
|
|
759
763
|
const result = await executeReconciliation({
|
|
@@ -794,11 +798,12 @@ describe('executeReconciliation - bulk create mode', () => {
|
|
|
794
798
|
account_id: txn.account_id,
|
|
795
799
|
amount: txn.amount,
|
|
796
800
|
date: txn.date,
|
|
801
|
+
payee_name: txn.payee_name,
|
|
802
|
+
memo: txn.memo,
|
|
797
803
|
cleared: 'cleared',
|
|
798
804
|
approved: true,
|
|
799
|
-
import_id: txn.import_id,
|
|
800
805
|
}));
|
|
801
|
-
return { data: { transactions } };
|
|
806
|
+
return { data: { transactions, duplicate_import_ids: [] } };
|
|
802
807
|
});
|
|
803
808
|
|
|
804
809
|
const result = await executeReconciliation({
|