@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
|
@@ -115,6 +115,9 @@ const convertSummary = (analysis: ReconciliationAnalysis) => ({
|
|
|
115
115
|
statement_date_range: analysis.summary.statement_date_range,
|
|
116
116
|
bank_transactions_count: analysis.summary.bank_transactions_count,
|
|
117
117
|
ynab_transactions_count: analysis.summary.ynab_transactions_count,
|
|
118
|
+
ynab_in_range_count:
|
|
119
|
+
analysis.summary.ynab_in_range_count ?? analysis.summary.ynab_transactions_count,
|
|
120
|
+
ynab_outside_range_count: analysis.summary.ynab_outside_range_count ?? 0,
|
|
118
121
|
auto_matched: analysis.summary.auto_matched,
|
|
119
122
|
suggested_matches: analysis.summary.suggested_matches,
|
|
120
123
|
unmatched_bank: analysis.summary.unmatched_bank,
|
|
@@ -292,6 +295,9 @@ export const buildReconciliationPayload = (
|
|
|
292
295
|
unmatched: {
|
|
293
296
|
bank: analysis.unmatched_bank.map((txn) => toBankTransactionView(txn, currency)),
|
|
294
297
|
ynab: analysis.unmatched_ynab.map((txn) => toYNABTransactionView(txn, currency)),
|
|
298
|
+
ynab_outside_date_range: (analysis.ynab_outside_date_range ?? []).map((txn) =>
|
|
299
|
+
toYNABTransactionView(txn, currency),
|
|
300
|
+
),
|
|
295
301
|
},
|
|
296
302
|
};
|
|
297
303
|
|
|
@@ -3,6 +3,17 @@ import type { ReconciliationAnalysis } from '../types.js';
|
|
|
3
3
|
import { buildReconciliationPayload } from '../../reconcileAdapter.js';
|
|
4
4
|
import type { LegacyReconciliationResult } from '../executor.js';
|
|
5
5
|
|
|
6
|
+
const makeMoney = (value: number, currency = 'USD') => ({
|
|
7
|
+
value_milliunits: Math.round(value * 1000),
|
|
8
|
+
value,
|
|
9
|
+
value_display: value < 0 ? `-$${Math.abs(value).toFixed(2)}` : `$${value.toFixed(2)}`,
|
|
10
|
+
currency,
|
|
11
|
+
direction: (value === 0 ? 'balanced' : value > 0 ? 'credit' : 'debit') as
|
|
12
|
+
| 'balanced'
|
|
13
|
+
| 'credit'
|
|
14
|
+
| 'debit',
|
|
15
|
+
});
|
|
16
|
+
|
|
6
17
|
const baseAnalysis: ReconciliationAnalysis = {
|
|
7
18
|
success: true,
|
|
8
19
|
phase: 'analysis',
|
|
@@ -10,25 +21,28 @@ const baseAnalysis: ReconciliationAnalysis = {
|
|
|
10
21
|
statement_date_range: '2025-10-01 to 2025-10-31',
|
|
11
22
|
bank_transactions_count: 1,
|
|
12
23
|
ynab_transactions_count: 1,
|
|
24
|
+
ynab_in_range_count: 1,
|
|
25
|
+
ynab_outside_range_count: 0,
|
|
13
26
|
auto_matched: 0,
|
|
14
27
|
suggested_matches: 0,
|
|
15
28
|
unmatched_bank: 0,
|
|
16
29
|
unmatched_ynab: 0,
|
|
17
|
-
current_cleared_balance: -899.02,
|
|
18
|
-
target_statement_balance: -921.24,
|
|
19
|
-
discrepancy: 22.22,
|
|
30
|
+
current_cleared_balance: makeMoney(-899.02),
|
|
31
|
+
target_statement_balance: makeMoney(-921.24),
|
|
32
|
+
discrepancy: makeMoney(22.22),
|
|
20
33
|
discrepancy_explanation: 'Need to investigate discrepancy',
|
|
21
34
|
},
|
|
22
35
|
auto_matches: [],
|
|
23
36
|
suggested_matches: [],
|
|
24
37
|
unmatched_bank: [],
|
|
25
38
|
unmatched_ynab: [],
|
|
39
|
+
ynab_outside_date_range: [],
|
|
26
40
|
balance_info: {
|
|
27
|
-
current_cleared: -899.02,
|
|
28
|
-
current_uncleared: 0,
|
|
29
|
-
current_total: -899.02,
|
|
30
|
-
target_statement: -921.24,
|
|
31
|
-
discrepancy: 22.22,
|
|
41
|
+
current_cleared: makeMoney(-899.02),
|
|
42
|
+
current_uncleared: makeMoney(0),
|
|
43
|
+
current_total: makeMoney(-899.02),
|
|
44
|
+
target_statement: makeMoney(-921.24),
|
|
45
|
+
discrepancy: makeMoney(22.22),
|
|
32
46
|
on_track: false,
|
|
33
47
|
},
|
|
34
48
|
next_steps: [],
|
|
@@ -23,6 +23,8 @@ const buildAnalysis = (): ReconciliationAnalysis => ({
|
|
|
23
23
|
statement_date_range: '2025-10-01 to 2025-10-31',
|
|
24
24
|
bank_transactions_count: 3,
|
|
25
25
|
ynab_transactions_count: 4,
|
|
26
|
+
ynab_in_range_count: 4,
|
|
27
|
+
ynab_outside_range_count: 0,
|
|
26
28
|
auto_matched: 2,
|
|
27
29
|
suggested_matches: 1,
|
|
28
30
|
unmatched_bank: 1,
|
|
@@ -115,6 +117,7 @@ const buildAnalysis = (): ReconciliationAnalysis => ({
|
|
|
115
117
|
memo: null,
|
|
116
118
|
},
|
|
117
119
|
],
|
|
120
|
+
ynab_outside_date_range: [],
|
|
118
121
|
balance_info: {
|
|
119
122
|
current_cleared: makeMoney(-899.02),
|
|
120
123
|
current_uncleared: makeMoney(-45.23),
|
|
@@ -336,5 +336,70 @@ describe('analyzer', () => {
|
|
|
336
336
|
expect(result.summary.statement_date_range).toContain('2025-10-15');
|
|
337
337
|
expect(result.summary.statement_date_range).toContain('2025-10-20');
|
|
338
338
|
});
|
|
339
|
+
|
|
340
|
+
it('should not flag transactions outside statement period as missing from bank', () => {
|
|
341
|
+
// Regression test for date range filtering bug
|
|
342
|
+
// Previously, ALL YNAB transactions were compared against bank CSV,
|
|
343
|
+
// causing transactions outside the statement period to be incorrectly
|
|
344
|
+
// flagged as "missing from bank"
|
|
345
|
+
vi.mocked(csvParser.parseCSV).mockReturnValue({
|
|
346
|
+
transactions: [
|
|
347
|
+
{
|
|
348
|
+
id: 'b1',
|
|
349
|
+
date: '2025-01-15',
|
|
350
|
+
amount: -50000,
|
|
351
|
+
payee: 'Grocery Store',
|
|
352
|
+
memo: '',
|
|
353
|
+
sourceRow: 1,
|
|
354
|
+
raw: {} as any,
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
meta: {
|
|
358
|
+
detectedDelimiter: ',',
|
|
359
|
+
detectedColumns: [],
|
|
360
|
+
totalRows: 1,
|
|
361
|
+
validRows: 1,
|
|
362
|
+
skippedRows: 0,
|
|
363
|
+
},
|
|
364
|
+
errors: [],
|
|
365
|
+
warnings: [],
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const ynabTxns: YNABAPITransaction[] = [
|
|
369
|
+
// Transaction OUTSIDE statement period (December, before January statement)
|
|
370
|
+
{
|
|
371
|
+
id: 'y-outside',
|
|
372
|
+
date: '2024-12-20',
|
|
373
|
+
amount: -30000,
|
|
374
|
+
payee_name: 'Old Transaction',
|
|
375
|
+
category_name: 'Shopping',
|
|
376
|
+
cleared: 'cleared' as const,
|
|
377
|
+
approved: true,
|
|
378
|
+
} as YNABAPITransaction,
|
|
379
|
+
// Transaction INSIDE statement period
|
|
380
|
+
{
|
|
381
|
+
id: 'y-inside',
|
|
382
|
+
date: '2025-01-15',
|
|
383
|
+
amount: -50000,
|
|
384
|
+
payee_name: 'Grocery Store',
|
|
385
|
+
category_name: 'Groceries',
|
|
386
|
+
cleared: 'uncleared' as const,
|
|
387
|
+
approved: true,
|
|
388
|
+
} as YNABAPITransaction,
|
|
389
|
+
];
|
|
390
|
+
|
|
391
|
+
const result = analyzeReconciliation('csv', undefined, ynabTxns, -50.0);
|
|
392
|
+
|
|
393
|
+
// The outside-range transaction should be in ynab_outside_date_range, not unmatched_ynab
|
|
394
|
+
expect(result.ynab_outside_date_range).toHaveLength(1);
|
|
395
|
+
expect(result.ynab_outside_date_range[0]?.id).toBe('y-outside');
|
|
396
|
+
|
|
397
|
+
// The summary should reflect the correct counts
|
|
398
|
+
expect(result.summary.ynab_in_range_count).toBe(1);
|
|
399
|
+
expect(result.summary.ynab_outside_range_count).toBe(1);
|
|
400
|
+
|
|
401
|
+
// The inside-range transaction should match and not be in unmatched
|
|
402
|
+
expect(result.unmatched_ynab).toHaveLength(0);
|
|
403
|
+
});
|
|
339
404
|
});
|
|
340
405
|
});
|
|
@@ -30,6 +30,7 @@ const createMockContext = (overrides?: Partial<RecommendationContext>): Recommen
|
|
|
30
30
|
suggested_matches: [],
|
|
31
31
|
unmatched_bank: [],
|
|
32
32
|
unmatched_ynab: [],
|
|
33
|
+
ynab_outside_date_range: [],
|
|
33
34
|
balance_info: {
|
|
34
35
|
current_cleared: makeMoney(100),
|
|
35
36
|
current_uncleared: makeMoney(0),
|
|
@@ -42,6 +43,8 @@ const createMockContext = (overrides?: Partial<RecommendationContext>): Recommen
|
|
|
42
43
|
statement_date_range: '2024-01-01 to 2024-01-31',
|
|
43
44
|
bank_transactions_count: 0,
|
|
44
45
|
ynab_transactions_count: 0,
|
|
46
|
+
ynab_in_range_count: 0,
|
|
47
|
+
ynab_outside_range_count: 0,
|
|
45
48
|
auto_matched: 0,
|
|
46
49
|
suggested_matches: 0,
|
|
47
50
|
unmatched_bank: 0,
|
|
@@ -39,6 +39,8 @@ const createTestAnalysis = (
|
|
|
39
39
|
statement_date_range: '2025-10-01 to 2025-10-31',
|
|
40
40
|
bank_transactions_count: 10,
|
|
41
41
|
ynab_transactions_count: 12,
|
|
42
|
+
ynab_in_range_count: 12,
|
|
43
|
+
ynab_outside_range_count: 0,
|
|
42
44
|
auto_matched: 8,
|
|
43
45
|
suggested_matches: 1,
|
|
44
46
|
unmatched_bank: 1,
|
|
@@ -52,6 +54,7 @@ const createTestAnalysis = (
|
|
|
52
54
|
suggested_matches: [],
|
|
53
55
|
unmatched_bank: [],
|
|
54
56
|
unmatched_ynab: [],
|
|
57
|
+
ynab_outside_date_range: [],
|
|
55
58
|
balance_info: {
|
|
56
59
|
current_cleared: makeMoney(-899.02),
|
|
57
60
|
current_uncleared: makeMoney(-50.0),
|
|
@@ -255,7 +258,7 @@ describe('reportFormatter', () => {
|
|
|
255
258
|
|
|
256
259
|
const report = formatHumanReadableReport(analysis);
|
|
257
260
|
|
|
258
|
-
expect(report).toContain('
|
|
261
|
+
expect(report).toContain('Missing from YNAB (bank transactions without matches):');
|
|
259
262
|
expect(report).toContain('2025-10-25');
|
|
260
263
|
expect(report).toContain('EvoCarShare');
|
|
261
264
|
expect(report).toContain('-$22.22');
|
|
@@ -17,6 +17,8 @@ const buildAnalysis = (currency = 'USD'): ReconciliationAnalysis => ({
|
|
|
17
17
|
statement_date_range: '2025-10-01 to 2025-10-31',
|
|
18
18
|
bank_transactions_count: 1,
|
|
19
19
|
ynab_transactions_count: 1,
|
|
20
|
+
ynab_in_range_count: 1,
|
|
21
|
+
ynab_outside_range_count: 0,
|
|
20
22
|
auto_matched: 0,
|
|
21
23
|
suggested_matches: 1,
|
|
22
24
|
unmatched_bank: 0,
|
|
@@ -30,6 +32,7 @@ const buildAnalysis = (currency = 'USD'): ReconciliationAnalysis => ({
|
|
|
30
32
|
suggested_matches: [] as TransactionMatch[],
|
|
31
33
|
unmatched_bank: [],
|
|
32
34
|
unmatched_ynab: [],
|
|
35
|
+
ynab_outside_date_range: [],
|
|
33
36
|
balance_info: {
|
|
34
37
|
current_cleared: makeMoney(-899.02, currency),
|
|
35
38
|
current_uncleared: makeMoney(0, currency),
|
|
@@ -60,7 +60,11 @@ describe('scenario: zero, negative, and large statements', () => {
|
|
|
60
60
|
const result = analyzeReconciliation('csv', undefined, ynabTxns, 0);
|
|
61
61
|
|
|
62
62
|
expect(result.summary.unmatched_bank).toBeGreaterThan(0);
|
|
63
|
-
|
|
63
|
+
// The YNAB transaction from Oct 31 is within the 7-day tolerance buffer
|
|
64
|
+
// of the statement period (Nov 1-2), so it's included for matching.
|
|
65
|
+
// Since it doesn't match any bank transactions, it goes to unmatched_ynab.
|
|
66
|
+
expect(result.summary.ynab_in_range_count).toBe(1);
|
|
67
|
+
expect(result.summary.unmatched_ynab).toBe(1);
|
|
64
68
|
expect(result.balance_info.discrepancy).not.toBeNaN();
|
|
65
69
|
});
|
|
66
70
|
});
|
|
@@ -2,6 +2,17 @@ import { describe, it, expect } from 'vitest';
|
|
|
2
2
|
import type { ReconciliationAnalysis } from '../types.js';
|
|
3
3
|
import { buildReconciliationPayload } from '../../reconcileAdapter.js';
|
|
4
4
|
|
|
5
|
+
const makeMoney = (value: number, currency = 'USD') => ({
|
|
6
|
+
value_milliunits: Math.round(value * 1000),
|
|
7
|
+
value,
|
|
8
|
+
value_display: value < 0 ? `-$${Math.abs(value).toFixed(2)}` : `$${value.toFixed(2)}`,
|
|
9
|
+
currency,
|
|
10
|
+
direction: (value === 0 ? 'balanced' : value > 0 ? 'credit' : 'debit') as
|
|
11
|
+
| 'balanced'
|
|
12
|
+
| 'credit'
|
|
13
|
+
| 'debit',
|
|
14
|
+
});
|
|
15
|
+
|
|
5
16
|
const minimalAnalysis: ReconciliationAnalysis = {
|
|
6
17
|
success: true,
|
|
7
18
|
phase: 'analysis',
|
|
@@ -9,25 +20,28 @@ const minimalAnalysis: ReconciliationAnalysis = {
|
|
|
9
20
|
statement_date_range: '2025-10-01 to 2025-10-31',
|
|
10
21
|
bank_transactions_count: 0,
|
|
11
22
|
ynab_transactions_count: 0,
|
|
23
|
+
ynab_in_range_count: 0,
|
|
24
|
+
ynab_outside_range_count: 0,
|
|
12
25
|
auto_matched: 0,
|
|
13
26
|
suggested_matches: 0,
|
|
14
27
|
unmatched_bank: 0,
|
|
15
28
|
unmatched_ynab: 0,
|
|
16
|
-
current_cleared_balance: 0,
|
|
17
|
-
target_statement_balance: 0,
|
|
18
|
-
discrepancy: 0,
|
|
29
|
+
current_cleared_balance: makeMoney(0),
|
|
30
|
+
target_statement_balance: makeMoney(0),
|
|
31
|
+
discrepancy: makeMoney(0),
|
|
19
32
|
discrepancy_explanation: 'Balanced',
|
|
20
33
|
},
|
|
21
34
|
auto_matches: [],
|
|
22
35
|
suggested_matches: [],
|
|
23
36
|
unmatched_bank: [],
|
|
24
37
|
unmatched_ynab: [],
|
|
38
|
+
ynab_outside_date_range: [],
|
|
25
39
|
balance_info: {
|
|
26
|
-
current_cleared: 0,
|
|
27
|
-
current_uncleared: 0,
|
|
28
|
-
current_total: 0,
|
|
29
|
-
target_statement: 0,
|
|
30
|
-
discrepancy: 0,
|
|
40
|
+
current_cleared: makeMoney(0),
|
|
41
|
+
current_uncleared: makeMoney(0),
|
|
42
|
+
current_total: makeMoney(0),
|
|
43
|
+
target_statement: makeMoney(0),
|
|
44
|
+
discrepancy: makeMoney(0),
|
|
31
45
|
on_track: true,
|
|
32
46
|
},
|
|
33
47
|
next_steps: [],
|
|
@@ -25,6 +25,97 @@ import { generateRecommendations } from './recommendationEngine.js';
|
|
|
25
25
|
|
|
26
26
|
// --- Helper Functions ---
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Calculate the date range from bank transactions
|
|
30
|
+
* Returns { minDate, maxDate } as ISO date strings (YYYY-MM-DD)
|
|
31
|
+
*/
|
|
32
|
+
function calculateDateRange(bankTransactions: BankTransaction[]): {
|
|
33
|
+
minDate: string;
|
|
34
|
+
maxDate: string;
|
|
35
|
+
} | null {
|
|
36
|
+
if (bankTransactions.length === 0) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const dates = bankTransactions
|
|
41
|
+
.map((t) => t.date)
|
|
42
|
+
.filter((d) => d && /^\d{4}-\d{2}-\d{2}$/.test(d))
|
|
43
|
+
.sort();
|
|
44
|
+
|
|
45
|
+
if (dates.length === 0) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
minDate: dates[0]!,
|
|
51
|
+
maxDate: dates[dates.length - 1]!,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Filter YNAB transactions to only those within the given date range
|
|
57
|
+
* Returns { inRange, outsideRange } arrays
|
|
58
|
+
*
|
|
59
|
+
* @param dateToleranceDays - Buffer to add to the date range to account for bank posting delays
|
|
60
|
+
*/
|
|
61
|
+
function filterByDateRange(
|
|
62
|
+
ynabTransactions: YNABTransaction[],
|
|
63
|
+
dateRange: { minDate: string; maxDate: string },
|
|
64
|
+
dateToleranceDays: number = 7,
|
|
65
|
+
): { inRange: YNABTransaction[]; outsideRange: YNABTransaction[] } {
|
|
66
|
+
// Validate dateToleranceDays is non-negative
|
|
67
|
+
if (dateToleranceDays < 0) {
|
|
68
|
+
console.warn(
|
|
69
|
+
`[filterByDateRange] dateToleranceDays must be non-negative, got ${dateToleranceDays}. Using 0.`,
|
|
70
|
+
);
|
|
71
|
+
dateToleranceDays = 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const inRange: YNABTransaction[] = [];
|
|
75
|
+
const outsideRange: YNABTransaction[] = [];
|
|
76
|
+
|
|
77
|
+
// Parse date parts and use Date.UTC to avoid timezone issues
|
|
78
|
+
// This prevents 'off-by-one-day' errors from timezone conversions
|
|
79
|
+
const minParts = dateRange.minDate.split('-').map(Number);
|
|
80
|
+
const maxParts = dateRange.maxDate.split('-').map(Number);
|
|
81
|
+
|
|
82
|
+
// Validate date parts are valid numbers
|
|
83
|
+
if (
|
|
84
|
+
minParts.length !== 3 ||
|
|
85
|
+
maxParts.length !== 3 ||
|
|
86
|
+
minParts.some((n) => !Number.isFinite(n)) ||
|
|
87
|
+
maxParts.some((n) => !Number.isFinite(n))
|
|
88
|
+
) {
|
|
89
|
+
console.warn(
|
|
90
|
+
`[filterByDateRange] Invalid date format in range: ${dateRange.minDate} to ${dateRange.maxDate} - returning all transactions`,
|
|
91
|
+
);
|
|
92
|
+
return { inRange: ynabTransactions, outsideRange: [] };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const [minYear, minMonth, minDay] = minParts as [number, number, number];
|
|
96
|
+
const [maxYear, maxMonth, maxDay] = maxParts as [number, number, number];
|
|
97
|
+
|
|
98
|
+
// Add buffer to date range to account for bank posting delays
|
|
99
|
+
// Note: Date.UTC automatically handles month rollover if day goes negative
|
|
100
|
+
// (e.g., day 3 - 7 days = -4 correctly rolls back to previous month)
|
|
101
|
+
const minDateWithBuffer = new Date(Date.UTC(minYear, minMonth - 1, minDay - dateToleranceDays));
|
|
102
|
+
const minDateStr = minDateWithBuffer.toISOString().split('T')[0]!;
|
|
103
|
+
|
|
104
|
+
const maxDateWithBuffer = new Date(Date.UTC(maxYear, maxMonth - 1, maxDay + dateToleranceDays));
|
|
105
|
+
const maxDateStr = maxDateWithBuffer.toISOString().split('T')[0]!;
|
|
106
|
+
|
|
107
|
+
for (const txn of ynabTransactions) {
|
|
108
|
+
// Compare dates as strings (YYYY-MM-DD format sorts correctly)
|
|
109
|
+
if (txn.date >= minDateStr && txn.date <= maxDateStr) {
|
|
110
|
+
inRange.push(txn);
|
|
111
|
+
} else {
|
|
112
|
+
outsideRange.push(txn);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { inRange, outsideRange };
|
|
117
|
+
}
|
|
118
|
+
|
|
28
119
|
function mapToTransactionMatch(result: MatchResult): TransactionMatch {
|
|
29
120
|
const candidates = result.candidates.map((c) => ({
|
|
30
121
|
ynab_transaction: c.ynabTransaction,
|
|
@@ -97,7 +188,8 @@ function calculateBalances(
|
|
|
97
188
|
|
|
98
189
|
function generateSummary(
|
|
99
190
|
bankTransactions: BankTransaction[],
|
|
100
|
-
|
|
191
|
+
ynabTransactionsInRange: YNABTransaction[],
|
|
192
|
+
ynabTransactionsOutsideRange: YNABTransaction[],
|
|
101
193
|
autoMatches: TransactionMatch[],
|
|
102
194
|
suggestedMatches: TransactionMatch[],
|
|
103
195
|
unmatchedBank: BankTransaction[],
|
|
@@ -108,6 +200,9 @@ function generateSummary(
|
|
|
108
200
|
const dates = bankTransactions.map((t) => t.date).sort();
|
|
109
201
|
const dateRange = dates.length > 0 ? `${dates[0]} to ${dates[dates.length - 1]}` : 'Unknown';
|
|
110
202
|
|
|
203
|
+
// Total YNAB transactions = in range + outside range
|
|
204
|
+
const totalYnabCount = ynabTransactionsInRange.length + ynabTransactionsOutsideRange.length;
|
|
205
|
+
|
|
111
206
|
// Build discrepancy explanation
|
|
112
207
|
let discrepancyExplanation = '';
|
|
113
208
|
if (balances.on_track) {
|
|
@@ -131,7 +226,9 @@ function generateSummary(
|
|
|
131
226
|
return {
|
|
132
227
|
statement_date_range: dateRange,
|
|
133
228
|
bank_transactions_count: bankTransactions.length,
|
|
134
|
-
ynab_transactions_count:
|
|
229
|
+
ynab_transactions_count: totalYnabCount,
|
|
230
|
+
ynab_in_range_count: ynabTransactionsInRange.length,
|
|
231
|
+
ynab_outside_range_count: ynabTransactionsOutsideRange.length,
|
|
135
232
|
auto_matched: autoMatches.length,
|
|
136
233
|
suggested_matches: suggestedMatches.length,
|
|
137
234
|
unmatched_bank: unmatchedBank.length,
|
|
@@ -367,13 +464,30 @@ export function analyzeReconciliation(
|
|
|
367
464
|
const csvParseWarnings = parseResult.warnings;
|
|
368
465
|
|
|
369
466
|
// Step 2: Normalize YNAB transactions
|
|
370
|
-
const
|
|
467
|
+
const allYNABTransactions = normalizeYNABTransactions(ynabTransactions);
|
|
468
|
+
|
|
469
|
+
// Step 2.5: Filter YNAB transactions by CSV date range
|
|
470
|
+
// Only compare transactions within the statement period (with tolerance buffer)
|
|
471
|
+
const csvDateRange = calculateDateRange(newBankTransactions);
|
|
472
|
+
let ynabInRange: YNABTransaction[];
|
|
473
|
+
let ynabOutsideRange: YNABTransaction[];
|
|
474
|
+
|
|
475
|
+
if (csvDateRange) {
|
|
476
|
+
const dateToleranceDays = config.dateToleranceDays ?? 7;
|
|
477
|
+
const filtered = filterByDateRange(allYNABTransactions, csvDateRange, dateToleranceDays);
|
|
478
|
+
ynabInRange = filtered.inRange;
|
|
479
|
+
ynabOutsideRange = filtered.outsideRange;
|
|
480
|
+
} else {
|
|
481
|
+
// No valid date range from CSV, use all transactions
|
|
482
|
+
ynabInRange = allYNABTransactions;
|
|
483
|
+
ynabOutsideRange = [];
|
|
484
|
+
}
|
|
371
485
|
|
|
372
|
-
// Step 3: Run
|
|
486
|
+
// Step 3: Run matching algorithm ONLY on YNAB transactions within date range
|
|
373
487
|
// Use normalizeConfig to convert legacy config to V2 format with defaults
|
|
374
488
|
const normalizedConfig = normalizeConfig(config);
|
|
375
489
|
|
|
376
|
-
const newMatches = findMatches(newBankTransactions,
|
|
490
|
+
const newMatches = findMatches(newBankTransactions, ynabInRange, normalizedConfig);
|
|
377
491
|
const matches: TransactionMatch[] = newMatches.map(mapToTransactionMatch);
|
|
378
492
|
|
|
379
493
|
// Categorize
|
|
@@ -397,25 +511,26 @@ export function analyzeReconciliation(
|
|
|
397
511
|
);
|
|
398
512
|
const unmatchedBank = unmatchedBankMatches.map((m) => m.bankTransaction);
|
|
399
513
|
|
|
400
|
-
// Find unmatched YNAB
|
|
514
|
+
// Find unmatched YNAB (only from in-range transactions)
|
|
401
515
|
const matchedYnabIds = new Set<string>();
|
|
402
516
|
matches.forEach((m) => {
|
|
403
517
|
if (m.ynabTransaction) matchedYnabIds.add(m.ynabTransaction.id);
|
|
404
518
|
});
|
|
405
|
-
const unmatchedYNAB =
|
|
519
|
+
const unmatchedYNAB = ynabInRange.filter((t) => !matchedYnabIds.has(t.id));
|
|
406
520
|
|
|
407
|
-
// Step 6: Calculate balances
|
|
521
|
+
// Step 6: Calculate balances (use ALL YNAB transactions for balance calculation)
|
|
408
522
|
const balances = calculateBalances(
|
|
409
|
-
|
|
523
|
+
allYNABTransactions,
|
|
410
524
|
statementBalance,
|
|
411
525
|
currency,
|
|
412
526
|
accountSnapshot,
|
|
413
527
|
);
|
|
414
528
|
|
|
415
|
-
// Step 7: Generate summary
|
|
529
|
+
// Step 7: Generate summary (with date range info)
|
|
416
530
|
const summary = generateSummary(
|
|
417
531
|
matches.map((m) => m.bankTransaction),
|
|
418
|
-
|
|
532
|
+
ynabInRange,
|
|
533
|
+
ynabOutsideRange,
|
|
419
534
|
autoMatches,
|
|
420
535
|
suggestedMatches,
|
|
421
536
|
unmatchedBank,
|
|
@@ -445,6 +560,7 @@ export function analyzeReconciliation(
|
|
|
445
560
|
suggested_matches: suggestedMatches,
|
|
446
561
|
unmatched_bank: unmatchedBank,
|
|
447
562
|
unmatched_ynab: unmatchedYNAB,
|
|
563
|
+
ynab_outside_date_range: ynabOutsideRange,
|
|
448
564
|
balance_info: balances,
|
|
449
565
|
next_steps: nextSteps,
|
|
450
566
|
insights,
|
|
@@ -137,6 +137,18 @@ function formatTransactionAnalysisSection(
|
|
|
137
137
|
lines.push(SECTION_DIVIDER);
|
|
138
138
|
|
|
139
139
|
const summary = analysis.summary;
|
|
140
|
+
|
|
141
|
+
// Show date range context if transactions were filtered
|
|
142
|
+
const outsideRangeCount = summary.ynab_outside_range_count ?? 0;
|
|
143
|
+
if (outsideRangeCount > 0) {
|
|
144
|
+
const inRangeCount = summary.ynab_in_range_count ?? summary.ynab_transactions_count;
|
|
145
|
+
lines.push(
|
|
146
|
+
`Comparing ${summary.bank_transactions_count} bank transactions with ${inRangeCount} YNAB transactions within statement period.`,
|
|
147
|
+
);
|
|
148
|
+
lines.push(`(${outsideRangeCount} YNAB transactions outside statement period - not compared)`);
|
|
149
|
+
lines.push('');
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
lines.push(
|
|
141
153
|
`- Automatically matched: ${summary.auto_matched} of ${summary.bank_transactions_count} transactions`,
|
|
142
154
|
);
|
|
@@ -147,7 +159,7 @@ function formatTransactionAnalysisSection(
|
|
|
147
159
|
// Show unmatched bank transactions (if any)
|
|
148
160
|
if (analysis.unmatched_bank.length > 0) {
|
|
149
161
|
lines.push('');
|
|
150
|
-
lines.push('
|
|
162
|
+
lines.push('Missing from YNAB (bank transactions without matches):');
|
|
151
163
|
const maxToShow = options.maxUnmatchedToShow ?? 5;
|
|
152
164
|
const toShow = analysis.unmatched_bank.slice(0, maxToShow);
|
|
153
165
|
|
|
@@ -160,10 +172,26 @@ function formatTransactionAnalysisSection(
|
|
|
160
172
|
}
|
|
161
173
|
}
|
|
162
174
|
|
|
175
|
+
// Show unmatched YNAB transactions within date range (if any)
|
|
176
|
+
if (analysis.unmatched_ynab.length > 0) {
|
|
177
|
+
lines.push('');
|
|
178
|
+
lines.push('Missing from bank statement (YNAB transactions without matches):');
|
|
179
|
+
const maxToShow = options.maxUnmatchedToShow ?? 5;
|
|
180
|
+
const toShow = analysis.unmatched_ynab.slice(0, maxToShow);
|
|
181
|
+
|
|
182
|
+
for (const txn of toShow) {
|
|
183
|
+
lines.push(formatYnabTransactionLine(txn));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (analysis.unmatched_ynab.length > maxToShow) {
|
|
187
|
+
lines.push(` ... and ${analysis.unmatched_ynab.length - maxToShow} more`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
163
191
|
// Show suggested matches (if any)
|
|
164
192
|
if (analysis.suggested_matches.length > 0) {
|
|
165
193
|
lines.push('');
|
|
166
|
-
lines.push('Suggested matches:');
|
|
194
|
+
lines.push('Suggested matches (review manually):');
|
|
167
195
|
const maxToShow = options.maxUnmatchedToShow ?? 3;
|
|
168
196
|
const toShow = analysis.suggested_matches.slice(0, maxToShow);
|
|
169
197
|
|
|
@@ -179,6 +207,15 @@ function formatTransactionAnalysisSection(
|
|
|
179
207
|
return lines.join('\n');
|
|
180
208
|
}
|
|
181
209
|
|
|
210
|
+
/**
|
|
211
|
+
* Format a YNAB transaction line
|
|
212
|
+
*/
|
|
213
|
+
function formatYnabTransactionLine(txn: YNABTransaction): string {
|
|
214
|
+
const amountStr = formatAmount(txn.amount);
|
|
215
|
+
const payee = txn.payee ?? 'Unknown';
|
|
216
|
+
return ` ${txn.date} - ${payee.substring(0, 40).padEnd(40)} ${amountStr}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
182
219
|
/**
|
|
183
220
|
* Format a bank transaction line
|
|
184
221
|
*/
|
|
@@ -75,6 +75,10 @@ export interface ReconciliationSummary {
|
|
|
75
75
|
statement_date_range: string;
|
|
76
76
|
bank_transactions_count: number;
|
|
77
77
|
ynab_transactions_count: number;
|
|
78
|
+
/** YNAB transactions within the statement date range (used for matching) */
|
|
79
|
+
ynab_in_range_count: number;
|
|
80
|
+
/** YNAB transactions outside the statement date range (not compared) */
|
|
81
|
+
ynab_outside_range_count: number;
|
|
78
82
|
auto_matched: number;
|
|
79
83
|
suggested_matches: number;
|
|
80
84
|
unmatched_bank: number;
|
|
@@ -118,6 +122,8 @@ export interface ReconciliationAnalysis {
|
|
|
118
122
|
suggested_matches: TransactionMatch[];
|
|
119
123
|
unmatched_bank: BankTransaction[];
|
|
120
124
|
unmatched_ynab: YNABTransaction[];
|
|
125
|
+
/** YNAB transactions outside the statement date range (not compared, expected) */
|
|
126
|
+
ynab_outside_date_range: YNABTransaction[];
|
|
121
127
|
balance_info: BalanceInfo;
|
|
122
128
|
next_steps: string[];
|
|
123
129
|
insights: ReconciliationInsight[];
|