@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.
@@ -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
- auto_match_threshold: z.number().min(0).max(100).optional().default(85),
138
- suggestion_threshold: z.number().min(0).max(100).optional().default(60),
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
- // Response options
147
- include_structured_data: z.boolean().optional().default(false),
148
- structured_content: z
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("full"),
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: params.auto_match_threshold ?? 85,
206
- suggestedMatchThreshold: params.suggestion_threshold ?? 60,
176
+ autoMatchThreshold,
177
+ suggestedMatchThreshold: suggestionThreshold,
207
178
  minimumCandidateScore: 40,
208
179
  exactDateBonus: 5,
209
180
  exactPayeeBonus: 10,
210
181
  };
211
182
 
212
- const accountResult = forceFullRefresh
213
- ? await deltaFetcher.fetchAccountsFull(params.budget_id)
214
- : await deltaFetcher.fetchAccounts(params.budget_id);
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
- // Determine whether to invert bank amounts
240
- // If invert_bank_amounts is explicitly set, use that value
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 ?? params.statement_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
- | "statement_start_date"
378
- | "csv_min_date_with_buffer"
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 = forceFullRefresh
397
- ? await deltaFetcher.fetchTransactionsByAccountFull(
398
- params.budget_id,
399
- params.account_id,
400
- sinceDateString,
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
- // Smart sign detection: If invert_bank_amounts not explicitly set, auto-detect
412
- let finalInvertAmounts = shouldInvertBankAmounts;
413
- if (
414
- params.invert_bank_amounts === undefined &&
415
- rawCsvResult.transactions.length > 0 &&
416
- normalizedYNAB.length > 0
417
- ) {
418
- const needsInversion = detectSignInversion(
419
- rawCsvResult.transactions,
420
- normalizedYNAB,
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
- if (needsInversion !== null) {
424
- if (needsInversion !== finalInvertAmounts) {
425
- narrativeNotes.push(
426
- needsInversion
427
- ? "Detected bank CSV amounts opposite YNAB; inverting bank amounts for matching."
428
- : "Detected bank CSV amounts already align with YNAB; using CSV amounts as-is.",
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
- finalInvertAmounts = needsInversion;
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: getDataFreshness(transactionsResult, forceFullRefresh),
449
- data_source: getAuditDataSource(transactionsResult, forceFullRefresh),
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(params.statement_date);
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
- // Schema expects: { human: string } OR { human: string, structured: object }
544
- const responseData: Record<string, unknown> = {
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
- - auto_match_threshold (number, optional): Score 0–100 required for automatic matching. Default: 85.
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; optionally structured JSON data.
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
  ):