@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.
Files changed (33) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/bundle/index.cjs +40 -40
  3. package/dist/tools/reconcileAdapter.js +3 -0
  4. package/dist/tools/reconciliation/analyzer.js +72 -7
  5. package/dist/tools/reconciliation/reportFormatter.js +26 -2
  6. package/dist/tools/reconciliation/types.d.ts +3 -0
  7. package/dist/tools/transactionSchemas.d.ts +309 -0
  8. package/dist/tools/transactionSchemas.js +215 -0
  9. package/dist/tools/transactionTools.d.ts +3 -281
  10. package/dist/tools/transactionTools.js +4 -559
  11. package/dist/tools/transactionUtils.d.ts +31 -0
  12. package/dist/tools/transactionUtils.js +349 -0
  13. package/docs/plans/2025-12-25-transaction-tools-refactor-design.md +211 -0
  14. package/docs/plans/2025-12-25-transaction-tools-refactor.md +905 -0
  15. package/package.json +4 -2
  16. package/scripts/run-all-tests.js +196 -0
  17. package/src/tools/__tests__/transactionSchemas.test.ts +1188 -0
  18. package/src/tools/__tests__/transactionUtils.test.ts +989 -0
  19. package/src/tools/reconcileAdapter.ts +6 -0
  20. package/src/tools/reconciliation/__tests__/adapter.causes.test.ts +22 -8
  21. package/src/tools/reconciliation/__tests__/adapter.test.ts +3 -0
  22. package/src/tools/reconciliation/__tests__/analyzer.test.ts +65 -0
  23. package/src/tools/reconciliation/__tests__/recommendationEngine.test.ts +3 -0
  24. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +4 -1
  25. package/src/tools/reconciliation/__tests__/scenarios/adapterCurrency.scenario.test.ts +3 -0
  26. package/src/tools/reconciliation/__tests__/scenarios/extremes.scenario.test.ts +5 -1
  27. package/src/tools/reconciliation/__tests__/schemaUrl.test.ts +22 -8
  28. package/src/tools/reconciliation/analyzer.ts +127 -11
  29. package/src/tools/reconciliation/reportFormatter.ts +39 -2
  30. package/src/tools/reconciliation/types.ts +6 -0
  31. package/src/tools/transactionSchemas.ts +453 -0
  32. package/src/tools/transactionTools.ts +102 -823
  33. 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('Unmatched bank transactions:');
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
- expect(result.summary.unmatched_ynab).toBeGreaterThan(0);
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
- ynabTransactions: YNABTransaction[],
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: ynabTransactions.length,
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 newYNABTransactions = normalizeYNABTransactions(ynabTransactions);
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 new matching algorithm
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, newYNABTransactions, normalizedConfig);
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 = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
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
- newYNABTransactions,
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
- newYNABTransactions,
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('Unmatched bank transactions:');
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[];