@dizzlkheinz/ynab-mcpb 0.15.0 → 0.16.0

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 (30) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/dist/bundle/index.cjs +50 -49
  3. package/dist/server/YNABMCPServer.d.ts +2 -6
  4. package/dist/server/YNABMCPServer.js +5 -1
  5. package/dist/server/resources.d.ts +17 -13
  6. package/dist/server/resources.js +237 -48
  7. package/dist/tools/reconcileAdapter.d.ts +1 -0
  8. package/dist/tools/reconcileAdapter.js +1 -0
  9. package/dist/tools/reconciliation/analyzer.d.ts +5 -1
  10. package/dist/tools/reconciliation/analyzer.js +10 -8
  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/docs/reference/API.md +144 -0
  18. package/docs/technical/reconciliation-system-architecture.md +2251 -0
  19. package/package.json +1 -1
  20. package/src/server/YNABMCPServer.ts +7 -0
  21. package/src/server/__tests__/resources.template.test.ts +198 -0
  22. package/src/server/__tests__/resources.test.ts +10 -2
  23. package/src/server/resources.ts +307 -62
  24. package/src/tools/reconcileAdapter.ts +2 -0
  25. package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +23 -23
  26. package/src/tools/reconciliation/analyzer.ts +18 -6
  27. package/src/tools/reconciliation/csvParser.ts +84 -18
  28. package/src/tools/reconciliation/executor.ts +58 -1
  29. package/src/tools/reconciliation/index.ts +112 -61
  30. package/src/tools/reconciliation/reportFormatter.ts +55 -37
@@ -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,11 +355,32 @@ 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
+ },
373
+ };
374
+
375
+ const initialAccount: AccountSnapshot = {
376
+ balance: accountData.balance,
377
+ cleared_balance: accountData.cleared_balance,
378
+ uncleared_balance: accountData.uncleared_balance,
350
379
  };
351
380
 
352
381
  // Perform analysis
353
382
  const analysis = analyzeReconciliation(
354
- parseResult ?? csvContent,
383
+ parseResult,
355
384
  params.csv_file_path,
356
385
  ynabTransactions,
357
386
  adjustedStatementBalance,
@@ -361,14 +390,9 @@ export async function handleReconcileAccount(
361
390
  params.budget_id,
362
391
  finalInvertAmounts, // Use smart-detected value
363
392
  csvOptions,
393
+ initialAccount,
364
394
  );
365
395
 
366
- const initialAccount: AccountSnapshot = {
367
- balance: accountData.balance,
368
- cleared_balance: accountData.cleared_balance,
369
- uncleared_balance: accountData.uncleared_balance,
370
- };
371
-
372
396
  let executionData: LegacyReconciliationResult | undefined;
373
397
  const wantsBalanceVerification = Boolean(params.statement_date);
374
398
  const shouldExecute =
@@ -402,6 +426,9 @@ export async function handleReconcileAccount(
402
426
  if (csvFormatForPayload !== undefined) {
403
427
  adapterOptions.csvFormat = csvFormatForPayload;
404
428
  }
429
+ if (narrativeNotes.length > 0) {
430
+ adapterOptions.notes = narrativeNotes;
431
+ }
405
432
 
406
433
  const payload = buildReconciliationPayload(analysis, adapterOptions, executionData);
407
434
 
@@ -491,3 +518,27 @@ function mapCsvFormatForPayload(format: ReconcileAccountRequest['csv_format'] |
491
518
  payee_column: coerceString(format.description_column, '') ?? null,
492
519
  };
493
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
+ }