@dizzlkheinz/ynab-mcpb 0.26.2 → 0.26.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/CLAUDE.md +1 -1
- package/dist/bundle/index.cjs +91 -93
- package/dist/tools/reconciliation/executor.js +5 -12
- package/dist/tools/reconciliation/index.d.ts +9 -12
- package/dist/tools/reconciliation/index.js +98 -90
- package/dist/tools/schemas/outputs/reconciliationOutputs.d.ts +81 -770
- package/dist/tools/schemas/outputs/reconciliationOutputs.js +38 -66
- package/package.json +1 -1
- package/src/__tests__/performance.test.ts +2 -4
- package/src/tools/reconciliation/__tests__/executor.integration.test.ts +2 -4
- package/src/tools/reconciliation/__tests__/executor.test.ts +6 -10
- package/src/tools/reconciliation/__tests__/index.test.ts +10 -6
- package/src/tools/reconciliation/__tests__/reconciliation.delta.integration.test.ts +1 -110
- package/src/tools/reconciliation/executor.ts +6 -13
- package/src/tools/reconciliation/index.ts +152 -137
- package/src/tools/schemas/outputs/__tests__/discrepancyDirection.test.ts +146 -312
- package/src/tools/schemas/outputs/reconciliationOutputs.ts +47 -84
|
@@ -61,41 +61,6 @@ export {
|
|
|
61
61
|
// Re-export types for external use
|
|
62
62
|
export type * from "./types.js";
|
|
63
63
|
|
|
64
|
-
/**
|
|
65
|
-
* Helper function to determine audit data source based on fetch result
|
|
66
|
-
*/
|
|
67
|
-
function getAuditDataSource(
|
|
68
|
-
transactionsResult: { usedDelta?: boolean; wasCached?: boolean },
|
|
69
|
-
forceFullRefresh: boolean,
|
|
70
|
-
): string {
|
|
71
|
-
if (forceFullRefresh) {
|
|
72
|
-
return "full_api_fetch_no_delta";
|
|
73
|
-
}
|
|
74
|
-
if (transactionsResult.usedDelta) {
|
|
75
|
-
return "delta_fetch_with_merge";
|
|
76
|
-
}
|
|
77
|
-
if (transactionsResult.wasCached) {
|
|
78
|
-
return "delta_fetch_cache_hit";
|
|
79
|
-
}
|
|
80
|
-
return "delta_fetch_full_refresh";
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* Helper function to determine data freshness based on fetch result
|
|
85
|
-
*/
|
|
86
|
-
function getDataFreshness(
|
|
87
|
-
transactionsResult: { wasCached?: boolean },
|
|
88
|
-
forceFullRefresh: boolean,
|
|
89
|
-
): string {
|
|
90
|
-
if (forceFullRefresh) {
|
|
91
|
-
return "guaranteed_fresh";
|
|
92
|
-
}
|
|
93
|
-
if (transactionsResult.wasCached) {
|
|
94
|
-
return "cache_validated_via_server_knowledge";
|
|
95
|
-
}
|
|
96
|
-
return "fresh_via_delta_fetch";
|
|
97
|
-
}
|
|
98
|
-
|
|
99
64
|
/**
|
|
100
65
|
* Schema for reconcile_account tool
|
|
101
66
|
*/
|
|
@@ -126,31 +91,28 @@ export const ReconcileAccountSchema = z
|
|
|
126
91
|
statement_balance: z.number({
|
|
127
92
|
message: "Statement balance is required and must be a number",
|
|
128
93
|
}),
|
|
129
|
-
statement_start_date: z.string().optional(),
|
|
130
94
|
statement_end_date: z.string().optional(),
|
|
131
|
-
statement_date: z.string().optional(),
|
|
132
|
-
expected_bank_balance: z.number().optional(),
|
|
133
|
-
as_of_timezone: z.string().optional(),
|
|
134
95
|
|
|
135
96
|
// Matching configuration (optional)
|
|
136
97
|
date_tolerance_days: z.number().min(0).max(7).optional().default(7),
|
|
137
|
-
|
|
138
|
-
|
|
98
|
+
match_strictness: z
|
|
99
|
+
.enum(["loose", "normal", "strict"])
|
|
100
|
+
.optional()
|
|
101
|
+
.default("normal"),
|
|
139
102
|
|
|
140
103
|
auto_create_transactions: z.boolean().optional().default(false),
|
|
141
104
|
auto_update_cleared_status: z.boolean().optional().default(false),
|
|
142
105
|
auto_unclear_missing: z.boolean().optional().default(true),
|
|
143
106
|
auto_adjust_dates: z.boolean().optional().default(false),
|
|
144
|
-
invert_bank_amounts: z.boolean().optional(),
|
|
145
107
|
dry_run: z.boolean().optional().default(true),
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
.enum(["full", "unmatched_only"])
|
|
108
|
+
// Sign convention override for bank CSV amounts
|
|
109
|
+
sign_convention: z
|
|
110
|
+
.enum(["auto", "invert", "as_is"])
|
|
150
111
|
.optional()
|
|
151
|
-
.default("
|
|
112
|
+
.default("auto"),
|
|
113
|
+
|
|
114
|
+
// Response options
|
|
152
115
|
max_suggestions_in_output: z.number().int().min(1).optional().default(20),
|
|
153
|
-
force_full_refresh: z.boolean().optional().default(true),
|
|
154
116
|
})
|
|
155
117
|
.refine((data) => data.csv_file_path || data.csv_data, {
|
|
156
118
|
message:
|
|
@@ -192,9 +154,18 @@ export async function handleReconcileAccount(
|
|
|
192
154
|
deltaFetcherOrParams,
|
|
193
155
|
maybeParams,
|
|
194
156
|
);
|
|
195
|
-
const forceFullRefresh = params.force_full_refresh ?? true;
|
|
196
157
|
return await withToolErrorHandling(
|
|
197
158
|
async () => {
|
|
159
|
+
// Derive matching thresholds from match_strictness
|
|
160
|
+
const STRICTNESS_THRESHOLDS = {
|
|
161
|
+
loose: { autoMatch: 70, suggested: 55 },
|
|
162
|
+
normal: { autoMatch: 85, suggested: 60 },
|
|
163
|
+
strict: { autoMatch: 93, suggested: 75 },
|
|
164
|
+
} as const;
|
|
165
|
+
const strictness = params.match_strictness ?? "normal";
|
|
166
|
+
const { autoMatch: autoMatchThreshold, suggested: suggestionThreshold } =
|
|
167
|
+
STRICTNESS_THRESHOLDS[strictness];
|
|
168
|
+
|
|
198
169
|
// Build matching configuration from parameters (V2 Format)
|
|
199
170
|
const config: MatchingConfig = {
|
|
200
171
|
weights: {
|
|
@@ -202,16 +173,16 @@ export async function handleReconcileAccount(
|
|
|
202
173
|
payee: 0.35,
|
|
203
174
|
},
|
|
204
175
|
dateToleranceDays: params.date_tolerance_days ?? 7,
|
|
205
|
-
autoMatchThreshold
|
|
206
|
-
suggestedMatchThreshold:
|
|
176
|
+
autoMatchThreshold,
|
|
177
|
+
suggestedMatchThreshold: suggestionThreshold,
|
|
207
178
|
minimumCandidateScore: 40,
|
|
208
179
|
exactDateBonus: 5,
|
|
209
180
|
exactPayeeBonus: 10,
|
|
210
181
|
};
|
|
211
182
|
|
|
212
|
-
const accountResult =
|
|
213
|
-
|
|
214
|
-
|
|
183
|
+
const accountResult = await deltaFetcher.fetchAccountsFull(
|
|
184
|
+
params.budget_id,
|
|
185
|
+
);
|
|
215
186
|
const accountData = accountResult.data.find(
|
|
216
187
|
(account) => account.id === params.account_id,
|
|
217
188
|
);
|
|
@@ -236,14 +207,8 @@ export async function handleReconcileAccount(
|
|
|
236
207
|
accountType === "otherDebt" ||
|
|
237
208
|
accountType === "otherLiability";
|
|
238
209
|
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
// Otherwise, default to true for liability accounts (legacy behavior)
|
|
242
|
-
// Note: Some banks (e.g., Wealthsimple) show charges as negative already, matching YNAB
|
|
243
|
-
const shouldInvertBankAmounts =
|
|
244
|
-
params.invert_bank_amounts !== undefined
|
|
245
|
-
? params.invert_bank_amounts
|
|
246
|
-
: accountIsLiability;
|
|
210
|
+
// Default inversion assumption: liability accounts typically show charges as positive
|
|
211
|
+
const shouldInvertBankAmounts = accountIsLiability;
|
|
247
212
|
|
|
248
213
|
// Negate statement balance for liability accounts
|
|
249
214
|
const adjustedStatementBalance = accountIsLiability
|
|
@@ -341,16 +306,10 @@ export async function handleReconcileAccount(
|
|
|
341
306
|
throw new Error(`Failed to parse CSV data: ${message}`);
|
|
342
307
|
}
|
|
343
308
|
|
|
344
|
-
const statementWindowStart = normalizeStatementDate(
|
|
345
|
-
params.statement_start_date,
|
|
346
|
-
);
|
|
347
309
|
const statementWindowEnd = normalizeStatementDate(
|
|
348
|
-
params.statement_end_date
|
|
310
|
+
params.statement_end_date,
|
|
349
311
|
);
|
|
350
312
|
const statementWindowBounds = {
|
|
351
|
-
...(statementWindowStart !== undefined && {
|
|
352
|
-
startDate: statementWindowStart,
|
|
353
|
-
}),
|
|
354
313
|
...(statementWindowEnd !== undefined && {
|
|
355
314
|
endDate: statementWindowEnd,
|
|
356
315
|
}),
|
|
@@ -371,17 +330,23 @@ export async function handleReconcileAccount(
|
|
|
371
330
|
);
|
|
372
331
|
}
|
|
373
332
|
|
|
333
|
+
const effectiveStatementEndDate =
|
|
334
|
+
statementWindowEnd ??
|
|
335
|
+
inferLatestTransactionDate(rawCsvResult.transactions);
|
|
336
|
+
if (
|
|
337
|
+
statementWindowEnd === undefined &&
|
|
338
|
+
effectiveStatementEndDate !== undefined
|
|
339
|
+
) {
|
|
340
|
+
narrativeNotes.push(
|
|
341
|
+
`Auto-detected statement_end_date=${effectiveStatementEndDate} from the latest CSV transaction for balance verification.`,
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
374
345
|
// Fetch YNAB transactions for the account using inferred date window
|
|
375
346
|
let sinceDate: Date;
|
|
376
|
-
let dateWindowSource:
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
| "fallback_90_days";
|
|
380
|
-
|
|
381
|
-
if (params.statement_start_date) {
|
|
382
|
-
sinceDate = new Date(params.statement_start_date);
|
|
383
|
-
dateWindowSource = "statement_start_date";
|
|
384
|
-
} else if (rawCsvResult.transactions.length > 0) {
|
|
347
|
+
let dateWindowSource: "csv_min_date_with_buffer" | "fallback_90_days";
|
|
348
|
+
|
|
349
|
+
if (rawCsvResult.transactions.length > 0) {
|
|
385
350
|
sinceDate = inferSinceDateFromTransactions(rawCsvResult.transactions);
|
|
386
351
|
dateWindowSource = "csv_min_date_with_buffer";
|
|
387
352
|
} else {
|
|
@@ -393,43 +358,49 @@ export async function handleReconcileAccount(
|
|
|
393
358
|
}
|
|
394
359
|
|
|
395
360
|
const sinceDateString = sinceDate.toISOString().split("T")[0];
|
|
396
|
-
const transactionsResult =
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
: await deltaFetcher.fetchTransactionsByAccount(
|
|
403
|
-
params.budget_id,
|
|
404
|
-
params.account_id,
|
|
405
|
-
sinceDateString,
|
|
406
|
-
);
|
|
361
|
+
const transactionsResult =
|
|
362
|
+
await deltaFetcher.fetchTransactionsByAccountFull(
|
|
363
|
+
params.budget_id,
|
|
364
|
+
params.account_id,
|
|
365
|
+
sinceDateString,
|
|
366
|
+
);
|
|
407
367
|
|
|
408
368
|
const ynabTransactions = transactionsResult.data;
|
|
409
369
|
const normalizedYNAB = normalizeYNABTransactions(ynabTransactions);
|
|
410
370
|
|
|
411
|
-
//
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
371
|
+
// Determine sign inversion: explicit override or auto-detect
|
|
372
|
+
const signConvention = params.sign_convention ?? "auto";
|
|
373
|
+
let finalInvertAmounts: boolean;
|
|
374
|
+
if (signConvention === "invert") {
|
|
375
|
+
finalInvertAmounts = true;
|
|
376
|
+
narrativeNotes.push(
|
|
377
|
+
"Using explicit sign_convention=invert; bank amounts will be negated.",
|
|
378
|
+
);
|
|
379
|
+
} else if (signConvention === "as_is") {
|
|
380
|
+
finalInvertAmounts = false;
|
|
381
|
+
narrativeNotes.push(
|
|
382
|
+
"Using explicit sign_convention=as_is; bank amounts used as-is.",
|
|
421
383
|
);
|
|
384
|
+
} else {
|
|
385
|
+
// Auto-detect sign convention; fall back to account-type default
|
|
386
|
+
finalInvertAmounts = shouldInvertBankAmounts;
|
|
387
|
+
if (rawCsvResult.transactions.length > 0 && normalizedYNAB.length > 0) {
|
|
388
|
+
const needsInversion = detectSignInversion(
|
|
389
|
+
rawCsvResult.transactions,
|
|
390
|
+
normalizedYNAB,
|
|
391
|
+
);
|
|
422
392
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
393
|
+
if (needsInversion !== null) {
|
|
394
|
+
if (needsInversion !== finalInvertAmounts) {
|
|
395
|
+
narrativeNotes.push(
|
|
396
|
+
needsInversion
|
|
397
|
+
? "Detected bank CSV amounts opposite YNAB; inverting bank amounts for matching."
|
|
398
|
+
: "Detected bank CSV amounts already align with YNAB; using CSV amounts as-is.",
|
|
399
|
+
);
|
|
400
|
+
}
|
|
431
401
|
|
|
432
|
-
|
|
402
|
+
finalInvertAmounts = needsInversion;
|
|
403
|
+
}
|
|
433
404
|
}
|
|
434
405
|
}
|
|
435
406
|
|
|
@@ -445,8 +416,8 @@ export async function handleReconcileAccount(
|
|
|
445
416
|
: rawCsvResult;
|
|
446
417
|
|
|
447
418
|
const auditMetadata = {
|
|
448
|
-
data_freshness:
|
|
449
|
-
data_source:
|
|
419
|
+
data_freshness: "guaranteed_fresh",
|
|
420
|
+
data_source: "full_api_fetch_no_delta",
|
|
450
421
|
server_knowledge: transactionsResult.serverKnowledge,
|
|
451
422
|
fetched_at: new Date().toISOString(),
|
|
452
423
|
accounts_count: accountResult.data.length,
|
|
@@ -494,8 +465,18 @@ export async function handleReconcileAccount(
|
|
|
494
465
|
initialAccount,
|
|
495
466
|
);
|
|
496
467
|
|
|
468
|
+
const effectiveParams =
|
|
469
|
+
effectiveStatementEndDate !== params.statement_end_date
|
|
470
|
+
? {
|
|
471
|
+
...params,
|
|
472
|
+
statement_end_date: effectiveStatementEndDate,
|
|
473
|
+
}
|
|
474
|
+
: params;
|
|
475
|
+
|
|
497
476
|
let executionData: LegacyReconciliationResult | undefined;
|
|
498
|
-
const wantsBalanceVerification = Boolean(
|
|
477
|
+
const wantsBalanceVerification = Boolean(
|
|
478
|
+
effectiveParams.statement_end_date,
|
|
479
|
+
);
|
|
499
480
|
const shouldExecute =
|
|
500
481
|
params.auto_create_transactions ||
|
|
501
482
|
params.auto_update_cleared_status ||
|
|
@@ -507,7 +488,7 @@ export async function handleReconcileAccount(
|
|
|
507
488
|
executionData = await executeReconciliation({
|
|
508
489
|
ynabAPI,
|
|
509
490
|
analysis,
|
|
510
|
-
params,
|
|
491
|
+
params: effectiveParams,
|
|
511
492
|
budgetId: params.budget_id,
|
|
512
493
|
accountId: params.account_id,
|
|
513
494
|
initialAccount,
|
|
@@ -540,27 +521,49 @@ export async function handleReconcileAccount(
|
|
|
540
521
|
);
|
|
541
522
|
|
|
542
523
|
// Build response payload matching ReconcileAccountOutputSchema
|
|
543
|
-
//
|
|
544
|
-
|
|
524
|
+
// Always includes unmatched_only structured data for agent consumption.
|
|
525
|
+
// Include execution summary when reconciliation actions were performed.
|
|
526
|
+
let executionSummary:
|
|
527
|
+
| {
|
|
528
|
+
transactions_created: number;
|
|
529
|
+
transactions_updated: number;
|
|
530
|
+
dates_adjusted: number;
|
|
531
|
+
dry_run: boolean;
|
|
532
|
+
balance_status: "balanced" | "unbalanced" | "not_verified";
|
|
533
|
+
recommendations: string[];
|
|
534
|
+
}
|
|
535
|
+
| undefined;
|
|
536
|
+
if (executionData) {
|
|
537
|
+
const balanceRecon = executionData.balance_reconciliation;
|
|
538
|
+
const balanceStatus: "balanced" | "unbalanced" | "not_verified" =
|
|
539
|
+
!wantsBalanceVerification
|
|
540
|
+
? "not_verified"
|
|
541
|
+
: balanceRecon?.status === "balanced"
|
|
542
|
+
? "balanced"
|
|
543
|
+
: "unbalanced";
|
|
544
|
+
|
|
545
|
+
executionSummary = {
|
|
546
|
+
transactions_created: executionData.summary.transactions_created,
|
|
547
|
+
transactions_updated: executionData.summary.transactions_updated,
|
|
548
|
+
dates_adjusted: executionData.summary.dates_adjusted,
|
|
549
|
+
dry_run: executionData.summary.dry_run,
|
|
550
|
+
balance_status: balanceStatus,
|
|
551
|
+
recommendations: executionData.recommendations,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
const structured = {
|
|
555
|
+
unmatched_bank: payload.structured.unmatched.bank,
|
|
556
|
+
unmatched_ynab: payload.structured.unmatched.ynab,
|
|
557
|
+
suggestions: payload.structured.matches.suggested,
|
|
558
|
+
...(executionSummary !== undefined && {
|
|
559
|
+
execution_summary: executionSummary,
|
|
560
|
+
}),
|
|
561
|
+
};
|
|
562
|
+
const responseData = {
|
|
545
563
|
human: payload.human,
|
|
564
|
+
structured,
|
|
546
565
|
};
|
|
547
566
|
|
|
548
|
-
// Only include structured data if requested (can be very large)
|
|
549
|
-
if (params.include_structured_data) {
|
|
550
|
-
if (params.structured_content === "unmatched_only") {
|
|
551
|
-
// Keep this field mapping aligned with StructuredReconciliationUnmatchedOnlySchema
|
|
552
|
-
// in src/tools/schemas/outputs/reconciliationOutputs.ts.
|
|
553
|
-
const filteredStructured = {
|
|
554
|
-
unmatched_bank: payload.structured.unmatched.bank,
|
|
555
|
-
unmatched_ynab: payload.structured.unmatched.ynab,
|
|
556
|
-
suggestions: payload.structured.matches.suggested,
|
|
557
|
-
};
|
|
558
|
-
responseData["structured"] = filteredStructured;
|
|
559
|
-
} else {
|
|
560
|
-
responseData["structured"] = payload.structured;
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
|
|
564
567
|
return {
|
|
565
568
|
content: [
|
|
566
569
|
{
|
|
@@ -619,17 +622,15 @@ Args:
|
|
|
619
622
|
- csv_file_path or csv_data (string, required): Bank export file path or inline CSV text.
|
|
620
623
|
- statement_balance (number, required): Ending balance from the bank statement (dollars).
|
|
621
624
|
For credit cards and other liability accounts, pass a negative value (e.g. -6143.27 means you owe $6,143.27).
|
|
625
|
+
- statement_end_date (string, optional): Statement closing date (YYYY-MM-DD). Filters CSV and triggers balance verification. Auto-detected from CSV if omitted.
|
|
626
|
+
- match_strictness (string, optional): Matching sensitivity — "loose" (more matches), "normal" (default), or "strict" (fewer false positives).
|
|
627
|
+
- sign_convention (string, optional): How to treat CSV amount signs — "auto" (default, detects from data), "invert" (negate all amounts), "as_is" (use amounts unchanged). Useful when auto-detection fails for liability accounts.
|
|
622
628
|
- dry_run (boolean, optional): Preview actions without executing. Default: true.
|
|
623
629
|
- auto_create_transactions (boolean, optional): Auto-create missing transactions. Default: false.
|
|
624
630
|
- auto_update_cleared_status (boolean, optional): Auto-mark matched transactions as cleared. Default: false.
|
|
625
|
-
-
|
|
626
|
-
Lower to 70–75 to match more transactions automatically; raise to 90+ for stricter matching.
|
|
627
|
-
- include_structured_data (boolean, optional): Include full JSON output alongside narrative. Default: false.
|
|
628
|
-
- structured_content (string, optional): "full" or "unmatched_only". Default: "full".
|
|
629
|
-
- max_suggestions_in_output (number, optional): Limit unmatched items and suggestions shown in the human report.
|
|
630
|
-
Default: 20.
|
|
631
|
+
- max_suggestions_in_output (number, optional): Limit unmatched items and suggestions shown in the human report. Default: 20.
|
|
631
632
|
|
|
632
|
-
Returns: human-readable reconciliation narrative
|
|
633
|
+
Returns: human-readable reconciliation narrative + structured JSON (unmatched_bank, unmatched_ynab, suggestions, execution_summary when actions are performed).
|
|
633
634
|
|
|
634
635
|
Examples:
|
|
635
636
|
- Preview reconciliation: set dry_run=true (default)
|
|
@@ -674,6 +675,20 @@ function mapCsvDateFormatToHint(
|
|
|
674
675
|
return undefined;
|
|
675
676
|
}
|
|
676
677
|
|
|
678
|
+
function inferLatestTransactionDate(
|
|
679
|
+
transactions: Array<{ date: string }>,
|
|
680
|
+
): string | undefined {
|
|
681
|
+
let latestDate: string | undefined;
|
|
682
|
+
|
|
683
|
+
for (const transaction of transactions) {
|
|
684
|
+
if (latestDate === undefined || transaction.date > latestDate) {
|
|
685
|
+
latestDate = transaction.date;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
return latestDate;
|
|
690
|
+
}
|
|
691
|
+
|
|
677
692
|
function mapCsvFormatForPayload(
|
|
678
693
|
format: ReconcileAccountRequest["csv_format"] | undefined,
|
|
679
694
|
):
|