@dizzlkheinz/ynab-mcpb 0.15.1 → 0.16.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/CLAUDE.md +113 -18
  3. package/README.md +19 -4
  4. package/dist/bundle/index.cjs +53 -52
  5. package/dist/server/YNABMCPServer.d.ts +2 -6
  6. package/dist/server/YNABMCPServer.js +5 -1
  7. package/dist/server/resources.d.ts +17 -13
  8. package/dist/server/resources.js +237 -48
  9. package/dist/tools/reconcileAdapter.d.ts +1 -0
  10. package/dist/tools/reconcileAdapter.js +1 -0
  11. package/dist/tools/reconciliation/csvParser.d.ts +3 -0
  12. package/dist/tools/reconciliation/csvParser.js +58 -19
  13. package/dist/tools/reconciliation/executor.js +47 -1
  14. package/dist/tools/reconciliation/index.js +82 -42
  15. package/dist/tools/reconciliation/reportFormatter.d.ts +1 -0
  16. package/dist/tools/reconciliation/reportFormatter.js +49 -36
  17. package/dist/tools/transactionTools.js +5 -0
  18. package/docs/reference/API.md +144 -0
  19. package/docs/technical/reconciliation-system-architecture.md +2251 -0
  20. package/package.json +1 -1
  21. package/src/server/YNABMCPServer.ts +7 -0
  22. package/src/server/__tests__/resources.template.test.ts +198 -0
  23. package/src/server/__tests__/resources.test.ts +10 -2
  24. package/src/server/resources.ts +307 -62
  25. package/src/tools/__tests__/transactionTools.test.ts +90 -17
  26. package/src/tools/reconcileAdapter.ts +2 -0
  27. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +23 -23
  28. package/src/tools/reconciliation/csvParser.ts +84 -18
  29. package/src/tools/reconciliation/executor.ts +58 -1
  30. package/src/tools/reconciliation/index.ts +105 -55
  31. package/src/tools/reconciliation/reportFormatter.ts +55 -37
  32. package/src/tools/transactionTools.ts +10 -0
  33. package/.dxtignore +0 -57
  34. package/CODEREVIEW_RESPONSE.md +0 -128
  35. package/SCHEMA_IMPROVEMENT_SUMMARY.md +0 -120
  36. package/TESTING_NOTES.md +0 -217
  37. package/accountactivity-merged.csv +0 -149
  38. package/bundle-analysis.html +0 -13110
  39. package/docs/plans/2025-11-20-reloadable-config-token-validation.md +0 -93
  40. package/docs/plans/2025-11-21-fix-transaction-cached-property.md +0 -362
  41. package/docs/plans/2025-11-21-reconciliation-error-handling.md +0 -90
  42. package/docs/plans/2025-11-21-v014-hardening.md +0 -153
  43. package/docs/plans/reconciliation-v2-redesign.md +0 -1571
  44. package/fix-types.sh +0 -17
  45. package/test-csv-sample.csv +0 -28
  46. package/test-exports/sample_bank_statement.csv +0 -7
  47. package/test-reconcile-autodetect.js +0 -40
  48. package/test-reconcile-tool.js +0 -152
  49. package/test-reconcile-with-csv.cjs +0 -89
  50. package/test-statement.csv +0 -8
  51. package/test_debug.js +0 -47
  52. package/test_mcp_tools.mjs +0 -75
  53. package/test_simple.mjs +0 -16
@@ -155,11 +155,11 @@ describe('reportFormatter', () => {
155
155
 
156
156
  const report = formatHumanReadableReport(analysis, options);
157
157
 
158
- expect(report).toContain('📊 Checking Account Reconciliation Report');
159
- expect(report).toContain(''.repeat(60));
160
- expect(report).toContain('BALANCE CHECK');
161
- expect(report).toContain('TRANSACTION ANALYSIS');
162
- expect(report).toContain('RECOMMENDED ACTIONS');
158
+ expect(report).toContain('Checking Account Reconciliation Report');
159
+ expect(report).toContain('-'.repeat(60));
160
+ expect(report).toContain('Balance Check');
161
+ expect(report).toContain('Transaction Analysis');
162
+ expect(report).toContain('Recommended Actions');
163
163
  });
164
164
 
165
165
  it('should show statement date range', () => {
@@ -187,8 +187,8 @@ describe('reportFormatter', () => {
187
187
 
188
188
  const report = formatHumanReadableReport(analysis);
189
189
 
190
- expect(report).toContain(' BALANCES MATCH PERFECTLY');
191
- expect(report).not.toContain('❌ DISCREPANCY');
190
+ expect(report).toContain('Balances match perfectly.');
191
+ expect(report).not.toContain('Discrepancy:');
192
192
  });
193
193
 
194
194
  it('should show discrepancy with correct direction when YNAB higher', () => {
@@ -209,7 +209,7 @@ describe('reportFormatter', () => {
209
209
 
210
210
  const report = formatHumanReadableReport(analysis);
211
211
 
212
- expect(report).toContain('❌ DISCREPANCY: $20.00');
212
+ expect(report).toContain('Discrepancy: $20.00');
213
213
  expect(report).toContain('YNAB shows MORE than statement');
214
214
  });
215
215
 
@@ -231,7 +231,7 @@ describe('reportFormatter', () => {
231
231
 
232
232
  const report = formatHumanReadableReport(analysis);
233
233
 
234
- expect(report).toContain('❌ DISCREPANCY: -$20.00');
234
+ expect(report).toContain('Discrepancy: -$20.00');
235
235
  expect(report).toContain('Statement shows MORE than YNAB');
236
236
  });
237
237
 
@@ -255,7 +255,7 @@ describe('reportFormatter', () => {
255
255
 
256
256
  const report = formatHumanReadableReport(analysis);
257
257
 
258
- expect(report).toContain(' UNMATCHED BANK TRANSACTIONS:');
258
+ expect(report).toContain('Unmatched bank transactions:');
259
259
  expect(report).toContain('2025-10-25');
260
260
  expect(report).toContain('EvoCarShare');
261
261
  expect(report).toContain('-$22.22');
@@ -291,7 +291,7 @@ describe('reportFormatter', () => {
291
291
 
292
292
  const report = formatHumanReadableReport(analysis);
293
293
 
294
- expect(report).toContain('💡 SUGGESTED MATCHES:');
294
+ expect(report).toContain('Suggested matches:');
295
295
  expect(report).toContain('Amazon');
296
296
  expect(report).toContain('75% confidence');
297
297
  });
@@ -318,9 +318,9 @@ describe('reportFormatter', () => {
318
318
 
319
319
  const report = formatHumanReadableReport(analysis);
320
320
 
321
- expect(report).toContain('KEY INSIGHTS');
322
- expect(report).toContain('🚨 Repeated amount detected');
323
- expect(report).toContain('⚠️ Near match found');
321
+ expect(report).toContain('Key Insights');
322
+ expect(report).toContain('[CRITICAL] Repeated amount detected');
323
+ expect(report).toContain('[WARN] Near match found');
324
324
  });
325
325
 
326
326
  it('should use correct severity icons', () => {
@@ -334,9 +334,9 @@ describe('reportFormatter', () => {
334
334
 
335
335
  const report = formatHumanReadableReport(analysis);
336
336
 
337
- expect(report).toContain('🚨 Critical Issue');
338
- expect(report).toContain('⚠️ Warning Issue');
339
- expect(report).toContain('ℹ️ Info Issue');
337
+ expect(report).toContain('[CRITICAL] Critical Issue');
338
+ expect(report).toContain('[WARN] Warning Issue');
339
+ expect(report).toContain('[INFO] Info Issue');
340
340
  });
341
341
 
342
342
  it('should truncate insights list', () => {
@@ -364,11 +364,11 @@ describe('reportFormatter', () => {
364
364
 
365
365
  const report = formatHumanReadableReport(analysis, {}, execution);
366
366
 
367
- expect(report).toContain('EXECUTION SUMMARY');
367
+ expect(report).toContain('Execution Summary');
368
368
  expect(report).toContain('Transactions created: 2');
369
369
  expect(report).toContain('Transactions updated: 3');
370
370
  expect(report).toContain('Date adjustments: 1');
371
- expect(report).toContain('Changes applied to YNAB');
371
+ expect(report).toContain('Changes applied to YNAB');
372
372
  });
373
373
 
374
374
  it('should show dry run notice when dry run enabled', () => {
@@ -384,7 +384,7 @@ describe('reportFormatter', () => {
384
384
 
385
385
  const report = formatHumanReadableReport(analysis, {}, execution);
386
386
 
387
- expect(report).toContain('⚠️ Dry run only no YNAB changes were applied.');
387
+ expect(report).toContain('NOTE: Dry run only - no YNAB changes were applied.');
388
388
  });
389
389
 
390
390
  it('should show execution recommendations', () => {
@@ -414,7 +414,7 @@ describe('reportFormatter', () => {
414
414
 
415
415
  const report = formatHumanReadableReport(analysis);
416
416
 
417
- expect(report).toContain('RECOMMENDED ACTIONS');
417
+ expect(report).toContain('Recommended Actions');
418
418
  expect(report).toContain('Create missing transaction for EvoCarShare');
419
419
  expect(report).toContain('Mark 8 transactions as cleared');
420
420
  });
@@ -433,7 +433,7 @@ describe('reportFormatter', () => {
433
433
  const analysis = createTestAnalysis();
434
434
  const report = formatHumanReadableReport(analysis);
435
435
 
436
- expect(report).toContain('📊 Account Reconciliation Report');
436
+ expect(report).toContain('Account Reconciliation Report');
437
437
  });
438
438
  });
439
439
 
@@ -548,7 +548,7 @@ describe('reportFormatter', () => {
548
548
  });
549
549
 
550
550
  const report = formatHumanReadableReport(analysis);
551
- expect(report).toContain(' BALANCES MATCH PERFECTLY');
551
+ expect(report).toContain('Balances match perfectly.');
552
552
  });
553
553
 
554
554
  it('should format insight evidence when available', () => {
@@ -95,11 +95,35 @@ export const BANK_PRESETS: Record<string, BankPreset> = {
95
95
  },
96
96
  };
97
97
 
98
+ /**
99
+ * Safe delimiters allowed for CSV parsing.
100
+ * Restricted to common, safe characters to prevent injection attacks.
101
+ */
102
+ export const SAFE_DELIMITERS = [',', ';', '\t', '|', ' '] as const;
103
+ export type SafeDelimiter = (typeof SAFE_DELIMITERS)[number];
104
+
105
+ /**
106
+ * Validates that a delimiter is safe for CSV parsing.
107
+ * @throws {Error} if delimiter is not in the safe list
108
+ */
109
+ function validateDelimiter(delimiter: string): asserts delimiter is SafeDelimiter {
110
+ if (!SAFE_DELIMITERS.includes(delimiter as SafeDelimiter)) {
111
+ throw new Error(
112
+ `Unsafe delimiter "${delimiter}". Allowed delimiters: ${SAFE_DELIMITERS.join(', ')}`,
113
+ );
114
+ }
115
+ }
116
+
98
117
  export interface ParseCSVOptions {
99
118
  /** Bank preset key (e.g., 'td', 'rbc') */
100
119
  preset?: string;
101
120
  /** Multiply all amounts by -1 */
102
121
  invertAmounts?: boolean;
122
+ /**
123
+ * Explicit CSV delimiter override (defaults to PapaParse auto-detection).
124
+ * Must be one of: comma (,), semicolon (;), tab (\t), pipe (|), or space ( )
125
+ */
126
+ delimiter?: string;
103
127
  /** Manual column overrides */
104
128
  columns?: {
105
129
  date?: string;
@@ -201,6 +225,11 @@ export function parseCSV(content: string, options: ParseCSVOptions = {}): CSVPar
201
225
  const errors: ParseError[] = [];
202
226
  const warnings: ParseWarning[] = [];
203
227
 
228
+ // Security: Validate delimiter if provided
229
+ if (options.delimiter) {
230
+ validateDelimiter(options.delimiter);
231
+ }
232
+
204
233
  // Security: Check file size limit
205
234
  const MAX_BYTES = options.maxBytes ?? 10 * 1024 * 1024; // 10MB default
206
235
  if (content.length > MAX_BYTES) {
@@ -244,6 +273,7 @@ export function parseCSV(content: string, options: ParseCSVOptions = {}): CSVPar
244
273
  dynamicTyping: false, // We'll handle type conversion ourselves
245
274
  skipEmptyLines: true,
246
275
  transformHeader: (h) => h.trim(),
276
+ ...(options.delimiter ? { delimiter: options.delimiter } : {}),
247
277
  });
248
278
 
249
279
  if (parsed.errors.length > 0) {
@@ -381,14 +411,46 @@ export function parseCSV(content: string, options: ParseCSVOptions = {}): CSVPar
381
411
 
382
412
  if (amountCol) {
383
413
  rawAmount = getValue(amountCol)?.trim() ?? '';
384
- amountMilliunits = dollarStringToMilliunits(rawAmount);
414
+ const parsedAmount = parseAmount(rawAmount);
415
+ if (!parsedAmount.valid) {
416
+ errors.push({
417
+ row: rowNum,
418
+ field: 'amount',
419
+ message: parsedAmount.reason ?? `Invalid amount: "${rawAmount}"`,
420
+ rawValue: rawAmount,
421
+ });
422
+ continue;
423
+ }
424
+ amountMilliunits = parsedAmount.valueMilliunits;
385
425
  } else if (debitCol && creditCol) {
386
426
  const debit = getValue(debitCol)?.trim() ?? '';
387
427
  const credit = getValue(creditCol)?.trim() ?? '';
388
428
  rawAmount = debit || credit;
389
429
 
390
- const debitMilliunits = dollarStringToMilliunits(debit);
391
- const creditMilliunits = dollarStringToMilliunits(credit);
430
+ const parsedDebit = parseAmount(debit);
431
+ const parsedCredit = parseAmount(credit);
432
+
433
+ if (!parsedDebit.valid && debit) {
434
+ errors.push({
435
+ row: rowNum,
436
+ field: 'amount',
437
+ message: parsedDebit.reason ?? `Invalid debit amount: "${debit}"`,
438
+ rawValue: debit,
439
+ });
440
+ continue;
441
+ }
442
+ if (!parsedCredit.valid && credit) {
443
+ errors.push({
444
+ row: rowNum,
445
+ field: 'amount',
446
+ message: parsedCredit.reason ?? `Invalid credit amount: "${credit}"`,
447
+ rawValue: credit,
448
+ });
449
+ continue;
450
+ }
451
+
452
+ const debitMilliunits = parsedDebit.valid ? parsedDebit.valueMilliunits : 0;
453
+ const creditMilliunits = parsedCredit.valid ? parsedCredit.valueMilliunits : 0;
392
454
 
393
455
  // Warn if both debit and credit have values (ambiguous)
394
456
  if (Math.abs(debitMilliunits) > 0 && Math.abs(creditMilliunits) > 0) {
@@ -402,7 +464,13 @@ export function parseCSV(content: string, options: ParseCSVOptions = {}): CSVPar
402
464
  } else if (Math.abs(creditMilliunits) > 0) {
403
465
  amountMilliunits = Math.abs(creditMilliunits); // Credits are inflows (positive)
404
466
  } else {
405
- amountMilliunits = 0;
467
+ errors.push({
468
+ row: rowNum,
469
+ field: 'amount',
470
+ message: 'Missing debit/credit amount',
471
+ rawValue: `${debit}|${credit}`,
472
+ });
473
+ continue;
406
474
  }
407
475
 
408
476
  // Warn if debit column contains negative value (unusual)
@@ -415,16 +483,6 @@ export function parseCSV(content: string, options: ParseCSVOptions = {}): CSVPar
415
483
  continue;
416
484
  }
417
485
 
418
- if (!Number.isFinite(amountMilliunits)) {
419
- errors.push({
420
- row: rowNum,
421
- field: 'amount',
422
- message: `Invalid amount: "${rawAmount}"`,
423
- rawValue: rawAmount,
424
- });
425
- continue;
426
- }
427
-
428
486
  // Apply amount inversion if needed
429
487
  const multiplier = options.invertAmounts ? -1 : (preset?.amountMultiplier ?? 1);
430
488
  amountMilliunits *= multiplier;
@@ -589,8 +647,14 @@ function detectPreset(columns: string[]): BankPreset | undefined {
589
647
  const CURRENCY_SYMBOLS = /[$€£¥]/g;
590
648
  const CURRENCY_CODES = /\b(CAD|USD|EUR|GBP)\b/gi;
591
649
 
592
- function dollarStringToMilliunits(str: string): number {
593
- if (!str) return 0;
650
+ function parseAmount(str: string): {
651
+ valid: boolean;
652
+ valueMilliunits: number;
653
+ reason?: string;
654
+ } {
655
+ if (!str || !str.trim()) {
656
+ return { valid: false, valueMilliunits: 0, reason: 'Missing amount value' };
657
+ }
594
658
 
595
659
  let cleaned = str.replace(CURRENCY_SYMBOLS, '').replace(CURRENCY_CODES, '').trim();
596
660
 
@@ -610,8 +674,10 @@ function dollarStringToMilliunits(str: string): number {
610
674
  }
611
675
 
612
676
  const dollars = parseFloat(cleaned);
613
- if (!Number.isFinite(dollars)) return 0;
677
+ if (!Number.isFinite(dollars)) {
678
+ return { valid: false, valueMilliunits: 0, reason: `Invalid amount: "${str}"` };
679
+ }
614
680
 
615
681
  // Convert to milliunits: $1.00 → 1000
616
- return Math.round(dollars * 1000);
682
+ return { valid: true, valueMilliunits: Math.round(dollars * 1000) };
617
683
  }
@@ -113,6 +113,11 @@ function truncateMemo(memo: string | null | undefined): string {
113
113
  return memo.substring(0, MAX_MEMO_LENGTH - 3) + '...';
114
114
  }
115
115
 
116
+ interface StatementWindow {
117
+ start?: Date;
118
+ end?: Date;
119
+ }
120
+
116
121
  interface PreparedBulkCreateEntry {
117
122
  bankTransaction: BankTransaction;
118
123
  saveTransaction: SaveTransaction;
@@ -144,6 +149,53 @@ function generateBulkImportId(
144
149
  return `YNAB:bulk:${digest}`;
145
150
  }
146
151
 
152
+ function parseISODate(dateStr: string | undefined): Date | undefined {
153
+ if (!dateStr) return undefined;
154
+ const d = new Date(dateStr);
155
+ return Number.isNaN(d.getTime()) ? undefined : d;
156
+ }
157
+
158
+ function resolveStatementWindow(
159
+ params: ReconcileAccountRequest,
160
+ analysisDateRange?: string | undefined,
161
+ ): StatementWindow | undefined {
162
+ const start = parseISODate(params.statement_start_date);
163
+ const end =
164
+ parseISODate(params.statement_end_date ?? params.statement_date) ??
165
+ // If only start provided, end stays undefined
166
+ undefined;
167
+
168
+ if (start || end) {
169
+ const window: StatementWindow = {};
170
+ if (start) window.start = start;
171
+ if (end) window.end = end;
172
+ return window;
173
+ }
174
+
175
+ if (analysisDateRange && analysisDateRange.includes(' to ')) {
176
+ const [rawStart, rawEnd] = analysisDateRange.split(' to ').map((part) => part.trim());
177
+ const parsedStart = parseISODate(rawStart);
178
+ const parsedEnd = parseISODate(rawEnd);
179
+ if (parsedStart || parsedEnd) {
180
+ const window: StatementWindow = {};
181
+ if (parsedStart) window.start = parsedStart;
182
+ if (parsedEnd) window.end = parsedEnd;
183
+ return window;
184
+ }
185
+ }
186
+
187
+ return undefined;
188
+ }
189
+
190
+ function isWithinStatementWindow(dateStr: string, window: StatementWindow): boolean {
191
+ const date = parseISODate(dateStr);
192
+ if (!date) return false;
193
+
194
+ if (window.start && date < window.start) return false;
195
+ if (window.end && date > window.end) return false;
196
+ return true;
197
+ }
198
+
147
199
  export async function executeReconciliation(options: ExecutionOptions): Promise<ExecutionResult> {
148
200
  const { analysis, params, ynabAPI, budgetId, accountId, initialAccount, currencyCode } = options;
149
201
  const actions_taken: ExecutionActionRecord[] = [];
@@ -202,7 +254,12 @@ export async function executeReconciliation(options: ExecutionOptions): Promise<
202
254
  ? sortByDateDescending(analysis.unmatched_bank)
203
255
  : [];
204
256
  const orderedAutoMatches = sortMatchesByBankDateDescending(analysis.auto_matches);
205
- const orderedUnmatchedYNAB = sortByDateDescending(analysis.unmatched_ynab);
257
+ const statementWindow = resolveStatementWindow(params, analysis.summary.statement_date_range);
258
+ const orderedUnmatchedYNAB = sortByDateDescending(
259
+ statementWindow
260
+ ? analysis.unmatched_ynab.filter((txn) => isWithinStatementWindow(txn.date, statementWindow))
261
+ : analysis.unmatched_ynab,
262
+ );
206
263
 
207
264
  let bulkOperationDetails: BulkOperationDetails | undefined;
208
265
 
@@ -22,6 +22,7 @@ import type { DeltaFetcher } from '../deltaFetcher.js';
22
22
  import { resolveDeltaFetcherArgs } from '../deltaSupport.js';
23
23
  import { detectSignInversion } from './signDetector.js';
24
24
  import { normalizeYNABTransactions } from './ynabAdapter.js';
25
+ import type { BankTransaction } from './types.js';
25
26
 
26
27
  // Re-export types for external use
27
28
  export type * from './types.js';
@@ -168,9 +169,9 @@ export async function handleReconcileAccount(
168
169
  date: 0.15,
169
170
  payee: 0.35,
170
171
  },
171
- dateToleranceDays: params.date_tolerance_days ?? 5,
172
+ dateToleranceDays: params.date_tolerance_days ?? 7,
172
173
  amountToleranceMilliunits: (params.amount_tolerance_cents ?? 1) * 10,
173
- autoMatchThreshold: params.auto_match_threshold ?? 90,
174
+ autoMatchThreshold: params.auto_match_threshold ?? 85,
174
175
  suggestedMatchThreshold: params.suggestion_threshold ?? 60,
175
176
  minimumCandidateScore: 40,
176
177
  exactAmountBonus: 10,
@@ -216,6 +217,8 @@ export async function handleReconcileAccount(
216
217
  const budgetResponse = await ynabAPI.budgets.getBudgetById(params.budget_id);
217
218
  const currencyCode = budgetResponse.data.budget?.currency_format?.iso_code ?? 'USD';
218
219
 
220
+ const narrativeNotes: string[] = [];
221
+
219
222
  // Prepare CSV parsing options from request
220
223
  const dateFormat = mapCsvDateFormatToHint(params.csv_format?.date_format);
221
224
  const csvOptions: ParseCSVOptions = {
@@ -240,6 +243,9 @@ export async function handleReconcileAccount(
240
243
  ...(params.csv_format?.has_header !== undefined && {
241
244
  header: params.csv_format.has_header,
242
245
  }),
246
+ ...(params.csv_format?.delimiter !== undefined && {
247
+ delimiter: params.csv_format.delimiter,
248
+ }),
243
249
  };
244
250
 
245
251
  // Load CSV content from either inline data or filesystem path
@@ -256,42 +262,44 @@ export async function handleReconcileAccount(
256
262
  }
257
263
  }
258
264
 
259
- // Fetch YNAB transactions for the account
260
- // Auto-detect date range from CSV if not explicitly provided
265
+ if (!csvContent.trim()) {
266
+ throw new Error('CSV content is empty after reading the provided source.');
267
+ }
268
+
269
+ // Initial parse without inversion for date window + sign detection
270
+ let rawCsvResult: CSVParseResult;
271
+ try {
272
+ rawCsvResult = parseCSV(csvContent, {
273
+ ...csvOptions,
274
+ invertAmounts: false,
275
+ });
276
+ } catch (error) {
277
+ const message =
278
+ error instanceof Error && error.message
279
+ ? error.message
280
+ : 'Unknown error while parsing CSV';
281
+ throw new Error(`Failed to parse CSV data: ${message}`);
282
+ }
283
+
284
+ // Fetch YNAB transactions for the account using inferred date window
261
285
  let sinceDate: Date;
262
- let parseResult: CSVParseResult | undefined;
286
+ let dateWindowSource:
287
+ | 'statement_start_date'
288
+ | 'csv_min_date_with_buffer'
289
+ | 'fallback_90_days';
263
290
 
264
291
  if (params.statement_start_date) {
265
- // User provided explicit start date
266
292
  sinceDate = new Date(params.statement_start_date);
293
+ dateWindowSource = 'statement_start_date';
294
+ } else if (rawCsvResult.transactions.length > 0) {
295
+ sinceDate = inferSinceDateFromTransactions(rawCsvResult.transactions);
296
+ dateWindowSource = 'csv_min_date_with_buffer';
267
297
  } else {
268
- // Auto-detect from CSV content using new parser
269
- try {
270
- parseResult = parseCSV(csvContent, {
271
- ...csvOptions,
272
- invertAmounts: shouldInvertBankAmounts,
273
- });
274
-
275
- if (parseResult.transactions.length > 0) {
276
- // Find min date
277
- const dates = parseResult.transactions
278
- .map((t) => new Date(t.date).getTime())
279
- .filter((t) => !isNaN(t));
280
- if (dates.length > 0) {
281
- const minTime = Math.min(...dates);
282
- const minDateObj = new Date(minTime);
283
- minDateObj.setDate(minDateObj.getDate() - 7); // 7-day buffer
284
- sinceDate = minDateObj;
285
- } else {
286
- sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
287
- }
288
- } else {
289
- sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
290
- }
291
- } catch {
292
- // Fallback to 90 days if CSV parsing fails
293
- sinceDate = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
294
- }
298
+ sinceDate = fallbackSinceDate();
299
+ dateWindowSource = 'fallback_90_days';
300
+ narrativeNotes.push(
301
+ 'CSV contained no parsable transactions for date detection; fetched the last 90 days from YNAB.',
302
+ );
295
303
  }
296
304
 
297
305
  const sinceDateString = sinceDate.toISOString().split('T')[0];
@@ -308,33 +316,33 @@ export async function handleReconcileAccount(
308
316
  );
309
317
 
310
318
  const ynabTransactions = transactionsResult.data;
319
+ const normalizedYNAB = normalizeYNABTransactions(ynabTransactions);
311
320
 
312
321
  // Smart sign detection: If invert_bank_amounts not explicitly set, auto-detect
313
322
  let finalInvertAmounts = shouldInvertBankAmounts;
314
- if (params.invert_bank_amounts === undefined && csvContent) {
315
- // Parse CSV without inversion to get raw amounts
316
- const rawParseResult = parseCSV(csvContent, {
317
- ...csvOptions,
318
- invertAmounts: false, // Don't invert yet
319
- });
320
-
321
- if (rawParseResult.transactions.length > 0 && ynabTransactions.length > 0) {
322
- // Normalize YNAB transactions for comparison
323
- const normalizedYNAB = normalizeYNABTransactions(ynabTransactions);
324
-
325
- // Detect if signs are mismatched
326
- const needsInversion = detectSignInversion(rawParseResult.transactions, normalizedYNAB);
327
-
328
- finalInvertAmounts = needsInversion;
329
-
330
- // If detection result differs from default, invalidate parseResult
331
- // to force re-parsing with correct inversion
332
- if (needsInversion !== shouldInvertBankAmounts && parseResult) {
333
- parseResult = undefined;
334
- }
323
+ if (
324
+ params.invert_bank_amounts === undefined &&
325
+ rawCsvResult.transactions.length > 0 &&
326
+ normalizedYNAB.length > 0
327
+ ) {
328
+ const needsInversion = detectSignInversion(rawCsvResult.transactions, normalizedYNAB);
329
+
330
+ if (needsInversion !== finalInvertAmounts) {
331
+ narrativeNotes.push(
332
+ needsInversion
333
+ ? 'Detected bank CSV amounts opposite YNAB; inverting bank amounts for matching.'
334
+ : 'Detected bank CSV amounts already align with YNAB; using CSV amounts as-is.',
335
+ );
335
336
  }
337
+
338
+ finalInvertAmounts = needsInversion;
336
339
  }
337
340
 
341
+ const parseResult =
342
+ finalInvertAmounts === false
343
+ ? rawCsvResult
344
+ : parseCSV(csvContent, { ...csvOptions, invertAmounts: finalInvertAmounts });
345
+
338
346
  const auditMetadata = {
339
347
  data_freshness: getDataFreshness(transactionsResult, forceFullRefresh),
340
348
  data_source: getAuditDataSource(transactionsResult, forceFullRefresh),
@@ -347,6 +355,21 @@ export async function handleReconcileAccount(
347
355
  transactions_cached: transactionsResult.wasCached,
348
356
  delta_merge_applied: transactionsResult.usedDelta,
349
357
  },
358
+ csv: {
359
+ rows: parseResult.meta.totalRows,
360
+ transactions: parseResult.transactions.length,
361
+ errors: parseResult.errors.length,
362
+ warnings: parseResult.warnings.length,
363
+ delimiter: parseResult.meta.detectedDelimiter,
364
+ },
365
+ date_window: {
366
+ since_date: sinceDateString,
367
+ source: dateWindowSource,
368
+ },
369
+ sign_detection: {
370
+ default_invert: shouldInvertBankAmounts,
371
+ final_invert: finalInvertAmounts,
372
+ },
350
373
  };
351
374
 
352
375
  const initialAccount: AccountSnapshot = {
@@ -357,7 +380,7 @@ export async function handleReconcileAccount(
357
380
 
358
381
  // Perform analysis
359
382
  const analysis = analyzeReconciliation(
360
- parseResult ?? csvContent,
383
+ parseResult,
361
384
  params.csv_file_path,
362
385
  ynabTransactions,
363
386
  adjustedStatementBalance,
@@ -403,6 +426,9 @@ export async function handleReconcileAccount(
403
426
  if (csvFormatForPayload !== undefined) {
404
427
  adapterOptions.csvFormat = csvFormatForPayload;
405
428
  }
429
+ if (narrativeNotes.length > 0) {
430
+ adapterOptions.notes = narrativeNotes;
431
+ }
406
432
 
407
433
  const payload = buildReconciliationPayload(analysis, adapterOptions, executionData);
408
434
 
@@ -492,3 +518,27 @@ function mapCsvFormatForPayload(format: ReconcileAccountRequest['csv_format'] |
492
518
  payee_column: coerceString(format.description_column, '') ?? null,
493
519
  };
494
520
  }
521
+
522
+ function fallbackSinceDate(): Date {
523
+ const date = new Date();
524
+ date.setDate(date.getDate() - 90);
525
+ return date;
526
+ }
527
+
528
+ function inferSinceDateFromTransactions(transactions: BankTransaction[]): Date {
529
+ if (transactions.length === 0) {
530
+ return fallbackSinceDate();
531
+ }
532
+
533
+ const timestamps = transactions
534
+ .map((t) => new Date(t.date).getTime())
535
+ .filter((time) => !Number.isNaN(time));
536
+
537
+ if (timestamps.length === 0) {
538
+ return fallbackSinceDate();
539
+ }
540
+
541
+ const minDate = new Date(Math.min(...timestamps));
542
+ minDate.setDate(minDate.getDate() - 7); // Add a small buffer
543
+ return minDate;
544
+ }