@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.
- package/CHANGELOG.md +36 -0
- package/CLAUDE.md +113 -18
- package/README.md +19 -4
- package/dist/bundle/index.cjs +53 -52
- package/dist/server/YNABMCPServer.d.ts +2 -6
- package/dist/server/YNABMCPServer.js +5 -1
- package/dist/server/resources.d.ts +17 -13
- package/dist/server/resources.js +237 -48
- package/dist/tools/reconcileAdapter.d.ts +1 -0
- package/dist/tools/reconcileAdapter.js +1 -0
- package/dist/tools/reconciliation/csvParser.d.ts +3 -0
- package/dist/tools/reconciliation/csvParser.js +58 -19
- package/dist/tools/reconciliation/executor.js +47 -1
- package/dist/tools/reconciliation/index.js +82 -42
- package/dist/tools/reconciliation/reportFormatter.d.ts +1 -0
- package/dist/tools/reconciliation/reportFormatter.js +49 -36
- package/dist/tools/transactionTools.js +5 -0
- package/docs/reference/API.md +144 -0
- package/docs/technical/reconciliation-system-architecture.md +2251 -0
- package/package.json +1 -1
- package/src/server/YNABMCPServer.ts +7 -0
- package/src/server/__tests__/resources.template.test.ts +198 -0
- package/src/server/__tests__/resources.test.ts +10 -2
- package/src/server/resources.ts +307 -62
- package/src/tools/__tests__/transactionTools.test.ts +90 -17
- package/src/tools/reconcileAdapter.ts +2 -0
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +23 -23
- package/src/tools/reconciliation/csvParser.ts +84 -18
- package/src/tools/reconciliation/executor.ts +58 -1
- package/src/tools/reconciliation/index.ts +105 -55
- package/src/tools/reconciliation/reportFormatter.ts +55 -37
- package/src/tools/transactionTools.ts +10 -0
- package/.dxtignore +0 -57
- package/CODEREVIEW_RESPONSE.md +0 -128
- package/SCHEMA_IMPROVEMENT_SUMMARY.md +0 -120
- package/TESTING_NOTES.md +0 -217
- package/accountactivity-merged.csv +0 -149
- package/bundle-analysis.html +0 -13110
- package/docs/plans/2025-11-20-reloadable-config-token-validation.md +0 -93
- package/docs/plans/2025-11-21-fix-transaction-cached-property.md +0 -362
- package/docs/plans/2025-11-21-reconciliation-error-handling.md +0 -90
- package/docs/plans/2025-11-21-v014-hardening.md +0 -153
- package/docs/plans/reconciliation-v2-redesign.md +0 -1571
- package/fix-types.sh +0 -17
- package/test-csv-sample.csv +0 -28
- package/test-exports/sample_bank_statement.csv +0 -7
- package/test-reconcile-autodetect.js +0 -40
- package/test-reconcile-tool.js +0 -152
- package/test-reconcile-with-csv.cjs +0 -89
- package/test-statement.csv +0 -8
- package/test_debug.js +0 -47
- package/test_mcp_tools.mjs +0 -75
- 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('
|
|
159
|
-
expect(report).toContain('
|
|
160
|
-
expect(report).toContain('
|
|
161
|
-
expect(report).toContain('
|
|
162
|
-
expect(report).toContain('
|
|
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('
|
|
191
|
-
expect(report).not.toContain('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
322
|
-
expect(report).toContain('
|
|
323
|
-
expect(report).toContain('
|
|
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('
|
|
338
|
-
expect(report).toContain('
|
|
339
|
-
expect(report).toContain('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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('
|
|
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
|
-
|
|
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
|
|
391
|
-
const
|
|
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
|
-
|
|
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
|
|
593
|
-
|
|
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))
|
|
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
|
|
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 ??
|
|
172
|
+
dateToleranceDays: params.date_tolerance_days ?? 7,
|
|
172
173
|
amountToleranceMilliunits: (params.amount_tolerance_cents ?? 1) * 10,
|
|
173
|
-
autoMatchThreshold: params.auto_match_threshold ??
|
|
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
|
-
|
|
260
|
-
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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 (
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
|
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
|
+
}
|