@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.
@@ -16,16 +16,9 @@ function parseISODate(dateStr) {
16
16
  return Number.isNaN(d.getTime()) ? undefined : d;
17
17
  }
18
18
  function resolveStatementWindow(params, analysisDateRange) {
19
- const start = parseISODate(params.statement_start_date);
20
- const end = parseISODate(params.statement_end_date ?? params.statement_date) ??
21
- undefined;
22
- if (start || end) {
23
- const window = {};
24
- if (start)
25
- window.start = start;
26
- if (end)
27
- window.end = end;
28
- return window;
19
+ const end = parseISODate(params.statement_end_date);
20
+ if (end) {
21
+ return { end };
29
22
  }
30
23
  if (analysisDateRange?.includes(" to ")) {
31
24
  const [rawStart, rawEnd] = analysisDateRange
@@ -637,12 +630,12 @@ export async function executeReconciliation(options) {
637
630
  }
638
631
  }
639
632
  let balance_reconciliation;
640
- if (params.statement_balance !== undefined && params.statement_date) {
633
+ if (params.statement_balance !== undefined && params.statement_end_date) {
641
634
  balance_reconciliation = await buildBalanceReconciliation({
642
635
  ynabAPI,
643
636
  budgetId,
644
637
  accountId,
645
- statementDate: params.statement_date,
638
+ statementDate: params.statement_end_date,
646
639
  statementBalanceMilli: statementTargetMilli,
647
640
  analysis,
648
641
  });
@@ -24,27 +24,24 @@ export declare const ReconcileAccountSchema: z.ZodObject<{
24
24
  delimiter: z.ZodOptional<z.ZodString>;
25
25
  }, z.core.$strict>>;
26
26
  statement_balance: z.ZodNumber;
27
- statement_start_date: z.ZodOptional<z.ZodString>;
28
27
  statement_end_date: z.ZodOptional<z.ZodString>;
29
- statement_date: z.ZodOptional<z.ZodString>;
30
- expected_bank_balance: z.ZodOptional<z.ZodNumber>;
31
- as_of_timezone: z.ZodOptional<z.ZodString>;
32
28
  date_tolerance_days: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
33
- auto_match_threshold: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
34
- suggestion_threshold: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
29
+ match_strictness: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
30
+ loose: "loose";
31
+ normal: "normal";
32
+ strict: "strict";
33
+ }>>>;
35
34
  auto_create_transactions: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
36
35
  auto_update_cleared_status: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
37
36
  auto_unclear_missing: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
38
37
  auto_adjust_dates: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
39
- invert_bank_amounts: z.ZodOptional<z.ZodBoolean>;
40
38
  dry_run: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
41
- include_structured_data: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
42
- structured_content: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
43
- full: "full";
44
- unmatched_only: "unmatched_only";
39
+ sign_convention: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
40
+ auto: "auto";
41
+ invert: "invert";
42
+ as_is: "as_is";
45
43
  }>>>;
46
44
  max_suggestions_in_output: z.ZodDefault<z.ZodOptional<z.ZodNumber>>;
47
- force_full_refresh: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
48
45
  }, z.core.$strip>;
49
46
  export type ReconcileAccountRequest = z.infer<typeof ReconcileAccountSchema>;
50
47
  export declare function handleReconcileAccount(ynabAPI: ynab.API, deltaFetcher: DeltaFetcher, params: ReconcileAccountRequest, sendProgress?: ProgressCallback): Promise<CallToolResult>;
@@ -18,27 +18,6 @@ import { normalizeYNABTransactions } from "./ynabAdapter.js";
18
18
  export { analyzeReconciliation } from "./analyzer.js";
19
19
  export { findBestMatch, findMatches } from "./matcher.js";
20
20
  export { fuzzyMatch, normalizedMatch, normalizePayee, payeeSimilarity, } from "./payeeNormalizer.js";
21
- function getAuditDataSource(transactionsResult, forceFullRefresh) {
22
- if (forceFullRefresh) {
23
- return "full_api_fetch_no_delta";
24
- }
25
- if (transactionsResult.usedDelta) {
26
- return "delta_fetch_with_merge";
27
- }
28
- if (transactionsResult.wasCached) {
29
- return "delta_fetch_cache_hit";
30
- }
31
- return "delta_fetch_full_refresh";
32
- }
33
- function getDataFreshness(transactionsResult, forceFullRefresh) {
34
- if (forceFullRefresh) {
35
- return "guaranteed_fresh";
36
- }
37
- if (transactionsResult.wasCached) {
38
- return "cache_validated_via_server_knowledge";
39
- }
40
- return "fresh_via_delta_fetch";
41
- }
42
21
  export const ReconcileAccountSchema = z
43
22
  .object({
44
23
  budget_id: z.string().min(1, "Budget ID is required"),
@@ -61,27 +40,22 @@ export const ReconcileAccountSchema = z
61
40
  statement_balance: z.number({
62
41
  message: "Statement balance is required and must be a number",
63
42
  }),
64
- statement_start_date: z.string().optional(),
65
43
  statement_end_date: z.string().optional(),
66
- statement_date: z.string().optional(),
67
- expected_bank_balance: z.number().optional(),
68
- as_of_timezone: z.string().optional(),
69
44
  date_tolerance_days: z.number().min(0).max(7).optional().default(7),
70
- auto_match_threshold: z.number().min(0).max(100).optional().default(85),
71
- suggestion_threshold: z.number().min(0).max(100).optional().default(60),
45
+ match_strictness: z
46
+ .enum(["loose", "normal", "strict"])
47
+ .optional()
48
+ .default("normal"),
72
49
  auto_create_transactions: z.boolean().optional().default(false),
73
50
  auto_update_cleared_status: z.boolean().optional().default(false),
74
51
  auto_unclear_missing: z.boolean().optional().default(true),
75
52
  auto_adjust_dates: z.boolean().optional().default(false),
76
- invert_bank_amounts: z.boolean().optional(),
77
53
  dry_run: z.boolean().optional().default(true),
78
- include_structured_data: z.boolean().optional().default(false),
79
- structured_content: z
80
- .enum(["full", "unmatched_only"])
54
+ sign_convention: z
55
+ .enum(["auto", "invert", "as_is"])
81
56
  .optional()
82
- .default("full"),
57
+ .default("auto"),
83
58
  max_suggestions_in_output: z.number().int().min(1).optional().default(20),
84
- force_full_refresh: z.boolean().optional().default(true),
85
59
  })
86
60
  .refine((data) => data.csv_file_path || data.csv_data, {
87
61
  message: "csv_data or csv_file_path is required. " +
@@ -92,23 +66,27 @@ export const ReconcileAccountSchema = z
92
66
  });
93
67
  export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, maybeParams, sendProgress, errorHandler) {
94
68
  const { deltaFetcher, params } = resolveDeltaFetcherArgs(ynabAPI, deltaFetcherOrParams, maybeParams);
95
- const forceFullRefresh = params.force_full_refresh ?? true;
96
69
  return await withToolErrorHandling(async () => {
70
+ const STRICTNESS_THRESHOLDS = {
71
+ loose: { autoMatch: 70, suggested: 55 },
72
+ normal: { autoMatch: 85, suggested: 60 },
73
+ strict: { autoMatch: 93, suggested: 75 },
74
+ };
75
+ const strictness = params.match_strictness ?? "normal";
76
+ const { autoMatch: autoMatchThreshold, suggested: suggestionThreshold } = STRICTNESS_THRESHOLDS[strictness];
97
77
  const config = {
98
78
  weights: {
99
79
  date: 0.15,
100
80
  payee: 0.35,
101
81
  },
102
82
  dateToleranceDays: params.date_tolerance_days ?? 7,
103
- autoMatchThreshold: params.auto_match_threshold ?? 85,
104
- suggestedMatchThreshold: params.suggestion_threshold ?? 60,
83
+ autoMatchThreshold,
84
+ suggestedMatchThreshold: suggestionThreshold,
105
85
  minimumCandidateScore: 40,
106
86
  exactDateBonus: 5,
107
87
  exactPayeeBonus: 10,
108
88
  };
109
- const accountResult = forceFullRefresh
110
- ? await deltaFetcher.fetchAccountsFull(params.budget_id)
111
- : await deltaFetcher.fetchAccounts(params.budget_id);
89
+ const accountResult = await deltaFetcher.fetchAccountsFull(params.budget_id);
112
90
  const accountData = accountResult.data.find((account) => account.id === params.account_id);
113
91
  if (!accountData) {
114
92
  throw new Error(`Account ${params.account_id} not found in budget ${params.budget_id}`);
@@ -124,9 +102,7 @@ export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, mayb
124
102
  accountType === "medicalDebt" ||
125
103
  accountType === "otherDebt" ||
126
104
  accountType === "otherLiability";
127
- const shouldInvertBankAmounts = params.invert_bank_amounts !== undefined
128
- ? params.invert_bank_amounts
129
- : accountIsLiability;
105
+ const shouldInvertBankAmounts = accountIsLiability;
130
106
  const adjustedStatementBalance = accountIsLiability
131
107
  ? -Math.abs(params.statement_balance)
132
108
  : params.statement_balance;
@@ -202,12 +178,8 @@ export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, mayb
202
178
  : "Unknown error while parsing CSV";
203
179
  throw new Error(`Failed to parse CSV data: ${message}`);
204
180
  }
205
- const statementWindowStart = normalizeStatementDate(params.statement_start_date);
206
- const statementWindowEnd = normalizeStatementDate(params.statement_end_date ?? params.statement_date);
181
+ const statementWindowEnd = normalizeStatementDate(params.statement_end_date);
207
182
  const statementWindowBounds = {
208
- ...(statementWindowStart !== undefined && {
209
- startDate: statementWindowStart,
210
- }),
211
183
  ...(statementWindowEnd !== undefined && {
212
184
  endDate: statementWindowEnd,
213
185
  }),
@@ -222,13 +194,15 @@ export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, mayb
222
194
  if (clampedCsv.windowApplied && rawCsvResult.transactions.length === 0) {
223
195
  throw new Error(`No CSV transactions remain after applying statement window ${formatStatementWindow(clampedCsv.windowApplied)}.`);
224
196
  }
197
+ const effectiveStatementEndDate = statementWindowEnd ??
198
+ inferLatestTransactionDate(rawCsvResult.transactions);
199
+ if (statementWindowEnd === undefined &&
200
+ effectiveStatementEndDate !== undefined) {
201
+ narrativeNotes.push(`Auto-detected statement_end_date=${effectiveStatementEndDate} from the latest CSV transaction for balance verification.`);
202
+ }
225
203
  let sinceDate;
226
204
  let dateWindowSource;
227
- if (params.statement_start_date) {
228
- sinceDate = new Date(params.statement_start_date);
229
- dateWindowSource = "statement_start_date";
230
- }
231
- else if (rawCsvResult.transactions.length > 0) {
205
+ if (rawCsvResult.transactions.length > 0) {
232
206
  sinceDate = inferSinceDateFromTransactions(rawCsvResult.transactions);
233
207
  dateWindowSource = "csv_min_date_with_buffer";
234
208
  }
@@ -238,23 +212,31 @@ export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, mayb
238
212
  narrativeNotes.push("CSV contained no parsable transactions for date detection; fetched the last 90 days from YNAB.");
239
213
  }
240
214
  const sinceDateString = sinceDate.toISOString().split("T")[0];
241
- const transactionsResult = forceFullRefresh
242
- ? await deltaFetcher.fetchTransactionsByAccountFull(params.budget_id, params.account_id, sinceDateString)
243
- : await deltaFetcher.fetchTransactionsByAccount(params.budget_id, params.account_id, sinceDateString);
215
+ const transactionsResult = await deltaFetcher.fetchTransactionsByAccountFull(params.budget_id, params.account_id, sinceDateString);
244
216
  const ynabTransactions = transactionsResult.data;
245
217
  const normalizedYNAB = normalizeYNABTransactions(ynabTransactions);
246
- let finalInvertAmounts = shouldInvertBankAmounts;
247
- if (params.invert_bank_amounts === undefined &&
248
- rawCsvResult.transactions.length > 0 &&
249
- normalizedYNAB.length > 0) {
250
- const needsInversion = detectSignInversion(rawCsvResult.transactions, normalizedYNAB);
251
- if (needsInversion !== null) {
252
- if (needsInversion !== finalInvertAmounts) {
253
- narrativeNotes.push(needsInversion
254
- ? "Detected bank CSV amounts opposite YNAB; inverting bank amounts for matching."
255
- : "Detected bank CSV amounts already align with YNAB; using CSV amounts as-is.");
218
+ const signConvention = params.sign_convention ?? "auto";
219
+ let finalInvertAmounts;
220
+ if (signConvention === "invert") {
221
+ finalInvertAmounts = true;
222
+ narrativeNotes.push("Using explicit sign_convention=invert; bank amounts will be negated.");
223
+ }
224
+ else if (signConvention === "as_is") {
225
+ finalInvertAmounts = false;
226
+ narrativeNotes.push("Using explicit sign_convention=as_is; bank amounts used as-is.");
227
+ }
228
+ else {
229
+ finalInvertAmounts = shouldInvertBankAmounts;
230
+ if (rawCsvResult.transactions.length > 0 && normalizedYNAB.length > 0) {
231
+ const needsInversion = detectSignInversion(rawCsvResult.transactions, normalizedYNAB);
232
+ if (needsInversion !== null) {
233
+ if (needsInversion !== finalInvertAmounts) {
234
+ narrativeNotes.push(needsInversion
235
+ ? "Detected bank CSV amounts opposite YNAB; inverting bank amounts for matching."
236
+ : "Detected bank CSV amounts already align with YNAB; using CSV amounts as-is.");
237
+ }
238
+ finalInvertAmounts = needsInversion;
256
239
  }
257
- finalInvertAmounts = needsInversion;
258
240
  }
259
241
  }
260
242
  const parseResult = finalInvertAmounts
@@ -267,8 +249,8 @@ export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, mayb
267
249
  }
268
250
  : rawCsvResult;
269
251
  const auditMetadata = {
270
- data_freshness: getDataFreshness(transactionsResult, forceFullRefresh),
271
- data_source: getAuditDataSource(transactionsResult, forceFullRefresh),
252
+ data_freshness: "guaranteed_fresh",
253
+ data_source: "full_api_fetch_no_delta",
272
254
  server_knowledge: transactionsResult.serverKnowledge,
273
255
  fetched_at: new Date().toISOString(),
274
256
  accounts_count: accountResult.data.length,
@@ -300,8 +282,14 @@ export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, mayb
300
282
  uncleared_balance: accountData.uncleared_balance,
301
283
  };
302
284
  const analysis = analyzeReconciliation(parseResult, params.csv_file_path, ynabTransactions, adjustedStatementBalance, config, currencyCode, params.account_id, params.budget_id, finalInvertAmounts, csvOptions, initialAccount);
285
+ const effectiveParams = effectiveStatementEndDate !== params.statement_end_date
286
+ ? {
287
+ ...params,
288
+ statement_end_date: effectiveStatementEndDate,
289
+ }
290
+ : params;
303
291
  let executionData;
304
- const wantsBalanceVerification = Boolean(params.statement_date);
292
+ const wantsBalanceVerification = Boolean(effectiveParams.statement_end_date);
305
293
  const shouldExecute = params.auto_create_transactions ||
306
294
  params.auto_update_cleared_status ||
307
295
  params.auto_unclear_missing ||
@@ -311,7 +299,7 @@ export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, mayb
311
299
  executionData = await executeReconciliation({
312
300
  ynabAPI,
313
301
  analysis,
314
- params,
302
+ params: effectiveParams,
315
303
  budgetId: params.budget_id,
316
304
  accountId: params.account_id,
317
305
  initialAccount,
@@ -335,22 +323,35 @@ export async function handleReconcileAccount(ynabAPI, deltaFetcherOrParams, mayb
335
323
  adapterOptions.notes = narrativeNotes;
336
324
  }
337
325
  const payload = buildReconciliationPayload(analysis, adapterOptions, executionData);
326
+ let executionSummary;
327
+ if (executionData) {
328
+ const balanceRecon = executionData.balance_reconciliation;
329
+ const balanceStatus = !wantsBalanceVerification
330
+ ? "not_verified"
331
+ : balanceRecon?.status === "balanced"
332
+ ? "balanced"
333
+ : "unbalanced";
334
+ executionSummary = {
335
+ transactions_created: executionData.summary.transactions_created,
336
+ transactions_updated: executionData.summary.transactions_updated,
337
+ dates_adjusted: executionData.summary.dates_adjusted,
338
+ dry_run: executionData.summary.dry_run,
339
+ balance_status: balanceStatus,
340
+ recommendations: executionData.recommendations,
341
+ };
342
+ }
343
+ const structured = {
344
+ unmatched_bank: payload.structured.unmatched.bank,
345
+ unmatched_ynab: payload.structured.unmatched.ynab,
346
+ suggestions: payload.structured.matches.suggested,
347
+ ...(executionSummary !== undefined && {
348
+ execution_summary: executionSummary,
349
+ }),
350
+ };
338
351
  const responseData = {
339
352
  human: payload.human,
353
+ structured,
340
354
  };
341
- if (params.include_structured_data) {
342
- if (params.structured_content === "unmatched_only") {
343
- const filteredStructured = {
344
- unmatched_bank: payload.structured.unmatched.bank,
345
- unmatched_ynab: payload.structured.unmatched.ynab,
346
- suggestions: payload.structured.matches.suggested,
347
- };
348
- responseData["structured"] = filteredStructured;
349
- }
350
- else {
351
- responseData["structured"] = payload.structured;
352
- }
353
- }
354
355
  return {
355
356
  content: [
356
357
  {
@@ -398,17 +399,15 @@ Args:
398
399
  - csv_file_path or csv_data (string, required): Bank export file path or inline CSV text.
399
400
  - statement_balance (number, required): Ending balance from the bank statement (dollars).
400
401
  For credit cards and other liability accounts, pass a negative value (e.g. -6143.27 means you owe $6,143.27).
402
+ - statement_end_date (string, optional): Statement closing date (YYYY-MM-DD). Filters CSV and triggers balance verification. Auto-detected from CSV if omitted.
403
+ - match_strictness (string, optional): Matching sensitivity — "loose" (more matches), "normal" (default), or "strict" (fewer false positives).
404
+ - 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.
401
405
  - dry_run (boolean, optional): Preview actions without executing. Default: true.
402
406
  - auto_create_transactions (boolean, optional): Auto-create missing transactions. Default: false.
403
407
  - auto_update_cleared_status (boolean, optional): Auto-mark matched transactions as cleared. Default: false.
404
- - auto_match_threshold (number, optional): Score 0–100 required for automatic matching. Default: 85.
405
- Lower to 70–75 to match more transactions automatically; raise to 90+ for stricter matching.
406
- - include_structured_data (boolean, optional): Include full JSON output alongside narrative. Default: false.
407
- - structured_content (string, optional): "full" or "unmatched_only". Default: "full".
408
- - max_suggestions_in_output (number, optional): Limit unmatched items and suggestions shown in the human report.
409
- Default: 20.
408
+ - max_suggestions_in_output (number, optional): Limit unmatched items and suggestions shown in the human report. Default: 20.
410
409
 
411
- Returns: human-readable reconciliation narrative; optionally structured JSON data.
410
+ Returns: human-readable reconciliation narrative + structured JSON (unmatched_bank, unmatched_ynab, suggestions, execution_summary when actions are performed).
412
411
 
413
412
  Examples:
414
413
  - Preview reconciliation: set dry_run=true (default)
@@ -443,6 +442,15 @@ function mapCsvDateFormatToHint(format) {
443
442
  }
444
443
  return undefined;
445
444
  }
445
+ function inferLatestTransactionDate(transactions) {
446
+ let latestDate;
447
+ for (const transaction of transactions) {
448
+ if (latestDate === undefined || transaction.date > latestDate) {
449
+ latestDate = transaction.date;
450
+ }
451
+ }
452
+ return latestDate;
453
+ }
446
454
  function mapCsvFormatForPayload(format) {
447
455
  if (!format) {
448
456
  return undefined;