@dizzlkheinz/ynab-mcpb 0.15.1 → 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.
@@ -69,7 +69,7 @@ export declare class YNABMCPServer {
69
69
  }[];
70
70
  }>;
71
71
  handleListResources(): Promise<{
72
- resources: import("./resources.js").ResourceDefinition[];
72
+ resources: import("@modelcontextprotocol/sdk/types.js").Resource[];
73
73
  }>;
74
74
  handleReadResource(params: {
75
75
  uri: string;
@@ -139,11 +139,7 @@ export declare class YNABMCPServer {
139
139
  } | undefined;
140
140
  isError?: boolean | undefined;
141
141
  } | {
142
- contents: {
143
- uri: string;
144
- mimeType: string;
145
- text: string;
146
- }[];
142
+ contents: import("@modelcontextprotocol/sdk/types.js").ResourceContents[];
147
143
  }>;
148
144
  handleListPrompts(): Promise<{
149
145
  prompts: import("./prompts.js").PromptDefinition[];
@@ -3,7 +3,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
3
3
  import fs from 'fs';
4
4
  import path from 'path';
5
5
  import { z } from 'zod';
6
- import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListPromptsRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
6
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ListPromptsRequestSchema, ReadResourceRequestSchema, GetPromptRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
7
7
  import * as ynab from 'ynab';
8
8
  import { AuthenticationError, ConfigurationError, ValidationError as ConfigValidationError, } from '../utils/errors.js';
9
9
  import { ValidationError } from '../types/index.js';
@@ -99,6 +99,7 @@ export class YNABMCPServer {
99
99
  this.resourceManager = new ResourceManager({
100
100
  ynabAPI: this.ynabAPI,
101
101
  responseFormatter,
102
+ cacheManager,
102
103
  });
103
104
  this.promptManager = new PromptManager();
104
105
  this.serverKnowledgeStore = new ServerKnowledgeStore();
@@ -158,6 +159,9 @@ export class YNABMCPServer {
158
159
  this.server.setRequestHandler(ListResourcesRequestSchema, async () => {
159
160
  return this.resourceManager.listResources();
160
161
  });
162
+ this.server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
163
+ return this.resourceManager.listResourceTemplates();
164
+ });
161
165
  this.server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
162
166
  const { uri } = request.params;
163
167
  try {
@@ -1,39 +1,43 @@
1
1
  import type * as ynab from 'ynab';
2
+ import { ResourceTemplate as MCPResourceTemplate, Resource as MCPResource, ResourceContents } from '@modelcontextprotocol/sdk/types.js';
3
+ import { CacheManager } from './cacheManager.js';
2
4
  interface ResponseFormatter {
3
5
  format(data: unknown): string;
4
6
  }
5
- export type ResourceHandler = (uri: string, dependencies: ResourceDependencies) => Promise<{
6
- contents: {
7
- uri: string;
8
- mimeType: string;
9
- text: string;
10
- }[];
11
- }>;
7
+ export type ResourceHandler = (uri: string, dependencies: ResourceDependencies) => Promise<ResourceContents[]>;
8
+ export type TemplateHandler = (uri: string, params: Record<string, string>, dependencies: ResourceDependencies) => Promise<ResourceContents[]>;
12
9
  export interface ResourceDefinition {
13
10
  uri: string;
14
11
  name: string;
15
12
  description: string;
16
13
  mimeType: string;
17
14
  }
15
+ export interface ResourceTemplateDefinition extends MCPResourceTemplate {
16
+ handler: TemplateHandler;
17
+ }
18
18
  export interface ResourceDependencies {
19
19
  ynabAPI: ynab.API;
20
20
  responseFormatter: ResponseFormatter;
21
+ cacheManager: CacheManager;
21
22
  }
22
23
  export declare class ResourceManager {
23
24
  private dependencies;
24
25
  private resourceHandlers;
25
26
  private resourceDefinitions;
27
+ private resourceTemplates;
26
28
  constructor(dependencies: ResourceDependencies);
27
29
  registerResource(definition: ResourceDefinition, handler: ResourceHandler): void;
30
+ registerTemplate(definition: ResourceTemplateDefinition): void;
28
31
  listResources(): {
29
- resources: ResourceDefinition[];
32
+ resources: MCPResource[];
33
+ };
34
+ listResourceTemplates(): {
35
+ resourceTemplates: MCPResourceTemplate[];
30
36
  };
31
37
  readResource(uri: string): Promise<{
32
- contents: {
33
- uri: string;
34
- mimeType: string;
35
- text: string;
36
- }[];
38
+ contents: ResourceContents[];
37
39
  }>;
40
+ private matchTemplate;
41
+ private validateTemplateDefinition;
38
42
  }
39
43
  export {};
@@ -1,49 +1,60 @@
1
+ import { CacheManager, CACHE_TTLS } from './cacheManager.js';
1
2
  const defaultResourceHandlers = {
2
- 'ynab://budgets': async (uri, { ynabAPI, responseFormatter }) => {
3
- try {
4
- const response = await ynabAPI.budgets.getBudgets();
5
- const budgets = response.data.budgets.map((budget) => ({
6
- id: budget.id,
7
- name: budget.name,
8
- last_modified_on: budget.last_modified_on,
9
- first_month: budget.first_month,
10
- last_month: budget.last_month,
11
- currency_format: budget.currency_format,
12
- }));
13
- return {
14
- contents: [
15
- {
16
- uri: uri,
17
- mimeType: 'application/json',
18
- text: responseFormatter.format({ budgets }),
19
- },
20
- ],
21
- };
22
- }
23
- catch (error) {
24
- throw new Error(`Failed to fetch budgets: ${error}`);
25
- }
3
+ 'ynab://budgets': async (uri, { ynabAPI, responseFormatter, cacheManager }) => {
4
+ const cacheKey = CacheManager.generateKey('resources', 'budgets', 'list');
5
+ return cacheManager.wrap(cacheKey, {
6
+ ttl: CACHE_TTLS.BUDGETS,
7
+ loader: async () => {
8
+ try {
9
+ const response = await ynabAPI.budgets.getBudgets();
10
+ const budgets = response.data.budgets.map((budget) => ({
11
+ id: budget.id,
12
+ name: budget.name,
13
+ last_modified_on: budget.last_modified_on,
14
+ first_month: budget.first_month,
15
+ last_month: budget.last_month,
16
+ currency_format: budget.currency_format,
17
+ }));
18
+ return [
19
+ {
20
+ uri: uri,
21
+ mimeType: 'application/json',
22
+ text: responseFormatter.format({ budgets }),
23
+ },
24
+ ];
25
+ }
26
+ catch (error) {
27
+ const message = error instanceof Error ? error.message : String(error);
28
+ throw new Error(`Failed to fetch budgets: ${message}`);
29
+ }
30
+ },
31
+ });
26
32
  },
27
- 'ynab://user': async (uri, { ynabAPI, responseFormatter }) => {
28
- try {
29
- const response = await ynabAPI.user.getUser();
30
- const userInfo = response.data.user;
31
- const user = {
32
- id: userInfo.id,
33
- };
34
- return {
35
- contents: [
36
- {
37
- uri: uri,
38
- mimeType: 'application/json',
39
- text: responseFormatter.format({ user }),
40
- },
41
- ],
42
- };
43
- }
44
- catch (error) {
45
- throw new Error(`Failed to fetch user info: ${error}`);
46
- }
33
+ 'ynab://user': async (uri, { ynabAPI, responseFormatter, cacheManager }) => {
34
+ const cacheKey = CacheManager.generateKey('resources', 'user');
35
+ return cacheManager.wrap(cacheKey, {
36
+ ttl: CACHE_TTLS.USER_INFO,
37
+ loader: async () => {
38
+ try {
39
+ const response = await ynabAPI.user.getUser();
40
+ const userInfo = response.data.user;
41
+ const user = {
42
+ id: userInfo.id,
43
+ };
44
+ return [
45
+ {
46
+ uri: uri,
47
+ mimeType: 'application/json',
48
+ text: responseFormatter.format({ user }),
49
+ },
50
+ ];
51
+ }
52
+ catch (error) {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ throw new Error(`Failed to fetch user info: ${message}`);
55
+ }
56
+ },
57
+ });
47
58
  },
48
59
  };
49
60
  const defaultResourceDefinitions = [
@@ -60,26 +71,204 @@ const defaultResourceDefinitions = [
60
71
  mimeType: 'application/json',
61
72
  },
62
73
  ];
74
+ const defaultResourceTemplates = [
75
+ {
76
+ uriTemplate: 'ynab://budgets/{budget_id}',
77
+ name: 'Budget Details',
78
+ description: 'Detailed information for a specific budget',
79
+ mimeType: 'application/json',
80
+ handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
81
+ const budget_id = params['budget_id'];
82
+ if (!budget_id)
83
+ throw new Error('Missing budget_id parameter');
84
+ const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
85
+ return cacheManager.wrap(cacheKey, {
86
+ ttl: CACHE_TTLS.BUDGETS,
87
+ loader: async () => {
88
+ try {
89
+ const response = await ynabAPI.budgets.getBudgetById(budget_id);
90
+ return [
91
+ {
92
+ uri,
93
+ mimeType: 'application/json',
94
+ text: responseFormatter.format(response.data.budget),
95
+ },
96
+ ];
97
+ }
98
+ catch (error) {
99
+ const message = error instanceof Error ? error.message : String(error);
100
+ throw new Error(`Failed to fetch budget ${budget_id}: ${message}`);
101
+ }
102
+ },
103
+ });
104
+ },
105
+ },
106
+ {
107
+ uriTemplate: 'ynab://budgets/{budget_id}/accounts',
108
+ name: 'Budget Accounts',
109
+ description: 'List of accounts for a specific budget',
110
+ mimeType: 'application/json',
111
+ handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
112
+ const budget_id = params['budget_id'];
113
+ if (!budget_id)
114
+ throw new Error('Missing budget_id parameter');
115
+ const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
116
+ return cacheManager.wrap(cacheKey, {
117
+ ttl: CACHE_TTLS.ACCOUNTS,
118
+ loader: async () => {
119
+ try {
120
+ const response = await ynabAPI.accounts.getAccounts(budget_id);
121
+ return [
122
+ {
123
+ uri,
124
+ mimeType: 'application/json',
125
+ text: responseFormatter.format(response.data.accounts),
126
+ },
127
+ ];
128
+ }
129
+ catch (error) {
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ throw new Error(`Failed to fetch accounts for budget ${budget_id}: ${message}`);
132
+ }
133
+ },
134
+ });
135
+ },
136
+ },
137
+ {
138
+ uriTemplate: 'ynab://budgets/{budget_id}/accounts/{account_id}',
139
+ name: 'Account Details',
140
+ description: 'Detailed information for a specific account within a budget',
141
+ mimeType: 'application/json',
142
+ handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
143
+ const budget_id = params['budget_id'];
144
+ const account_id = params['account_id'];
145
+ if (!budget_id)
146
+ throw new Error('Missing budget_id parameter');
147
+ if (!account_id)
148
+ throw new Error('Missing account_id parameter');
149
+ const cacheKey = CacheManager.generateKey('resources', 'accounts', 'get', budget_id, account_id);
150
+ return cacheManager.wrap(cacheKey, {
151
+ ttl: CACHE_TTLS.ACCOUNTS,
152
+ loader: async () => {
153
+ try {
154
+ const response = await ynabAPI.accounts.getAccountById(budget_id, account_id);
155
+ return [
156
+ {
157
+ uri,
158
+ mimeType: 'application/json',
159
+ text: responseFormatter.format(response.data.account),
160
+ },
161
+ ];
162
+ }
163
+ catch (error) {
164
+ const message = error instanceof Error ? error.message : String(error);
165
+ throw new Error(`Failed to fetch account ${account_id} in budget ${budget_id}: ${message}`);
166
+ }
167
+ },
168
+ });
169
+ },
170
+ },
171
+ ];
63
172
  export class ResourceManager {
64
173
  constructor(dependencies) {
65
174
  this.dependencies = dependencies;
66
175
  this.resourceHandlers = { ...defaultResourceHandlers };
67
176
  this.resourceDefinitions = [...defaultResourceDefinitions];
177
+ this.resourceTemplates = [];
178
+ defaultResourceTemplates.forEach((template) => this.registerTemplate(template));
68
179
  }
69
180
  registerResource(definition, handler) {
70
181
  this.resourceDefinitions.push(definition);
71
182
  this.resourceHandlers[definition.uri] = handler;
72
183
  }
184
+ registerTemplate(definition) {
185
+ this.validateTemplateDefinition(definition);
186
+ this.resourceTemplates.push(definition);
187
+ }
73
188
  listResources() {
74
189
  return {
75
- resources: this.resourceDefinitions,
190
+ resources: this.resourceDefinitions.map((r) => ({
191
+ uri: r.uri,
192
+ name: r.name,
193
+ description: r.description,
194
+ mimeType: r.mimeType,
195
+ })),
196
+ };
197
+ }
198
+ listResourceTemplates() {
199
+ return {
200
+ resourceTemplates: this.resourceTemplates.map((t) => ({
201
+ uriTemplate: t.uriTemplate,
202
+ name: t.name,
203
+ description: t.description,
204
+ mimeType: t.mimeType,
205
+ })),
76
206
  };
77
207
  }
78
208
  async readResource(uri) {
79
209
  const handler = this.resourceHandlers[uri];
80
- if (!handler) {
81
- throw new Error(`Unknown resource: ${uri}`);
210
+ if (handler) {
211
+ return { contents: await handler(uri, this.dependencies) };
212
+ }
213
+ for (const template of this.resourceTemplates) {
214
+ const params = this.matchTemplate(template.uriTemplate, uri);
215
+ if (params) {
216
+ try {
217
+ return { contents: await template.handler(uri, params, this.dependencies) };
218
+ }
219
+ catch (error) {
220
+ throw new Error(`Failed to resolve template resource ${uri}: ${error instanceof Error ? error.message : String(error)}`);
221
+ }
222
+ }
223
+ }
224
+ throw new Error(`Unknown resource: ${uri}`);
225
+ }
226
+ matchTemplate(template, uri) {
227
+ if (!/^[a-z0-9:/\-_{}]+$/i.test(template)) {
228
+ throw new Error('Invalid template format: contains unsafe characters');
229
+ }
230
+ const paramNames = [];
231
+ const regexPattern = template
232
+ .replace(/[.*+?^$()|[\]\\]/g, '\\$&')
233
+ .replace(/{([a-z_][a-z0-9_]*)}/gi, (_, name) => {
234
+ paramNames.push(name);
235
+ return '([^/]+)';
236
+ });
237
+ const regex = new RegExp(`^${regexPattern}$`);
238
+ const match = uri.match(regex);
239
+ if (match) {
240
+ const result = {};
241
+ paramNames.forEach((name, i) => {
242
+ const value = match[i + 1];
243
+ if (value) {
244
+ if (value.includes('..') || value.includes('\\')) {
245
+ throw new Error(`Invalid parameter value: ${name}=${value}`);
246
+ }
247
+ result[name] = value;
248
+ }
249
+ });
250
+ return result;
251
+ }
252
+ return null;
253
+ }
254
+ validateTemplateDefinition(definition) {
255
+ const { uriTemplate } = definition;
256
+ if (!/^[a-z0-9:/\-_{}]+$/i.test(uriTemplate)) {
257
+ throw new Error(`Invalid template format: contains unsafe characters (${uriTemplate})`);
258
+ }
259
+ const placeholderPattern = /{([^}]+)}/g;
260
+ const paramNames = [];
261
+ let match;
262
+ while ((match = placeholderPattern.exec(uriTemplate)) !== null) {
263
+ const paramName = match[1] ?? '';
264
+ if (!/^[a-z_][a-z0-9_]*$/i.test(paramName)) {
265
+ throw new Error(`Invalid template parameter name '${paramName}' in template ${uriTemplate}`);
266
+ }
267
+ paramNames.push(paramName);
268
+ }
269
+ const uniqueNames = new Set(paramNames);
270
+ if (uniqueNames.size !== paramNames.length) {
271
+ throw new Error(`Duplicate parameter names detected in template ${uriTemplate}`);
82
272
  }
83
- return await handler(uri, this.dependencies);
84
273
  }
85
274
  }
@@ -6,6 +6,7 @@ interface AdapterOptions {
6
6
  currencyCode?: string;
7
7
  csvFormat?: CsvFormatPayload;
8
8
  auditMetadata?: Record<string, unknown>;
9
+ notes?: string[];
9
10
  }
10
11
  interface DualChannelPayload {
11
12
  human: string;
@@ -126,6 +126,7 @@ const buildHumanNarrative = (analysis, options, execution) => {
126
126
  includeDetailedMatches: false,
127
127
  maxUnmatchedToShow: 5,
128
128
  maxInsightsToShow: 3,
129
+ notes: options.notes,
129
130
  };
130
131
  return formatHumanReadableReport(analysis, formatterOptions, execution);
131
132
  };
@@ -33,9 +33,12 @@ export interface BankPreset {
33
33
  header?: boolean;
34
34
  }
35
35
  export declare const BANK_PRESETS: Record<string, BankPreset>;
36
+ export declare const SAFE_DELIMITERS: readonly [",", ";", "\t", "|", " "];
37
+ export type SafeDelimiter = (typeof SAFE_DELIMITERS)[number];
36
38
  export interface ParseCSVOptions {
37
39
  preset?: string;
38
40
  invertAmounts?: boolean;
41
+ delimiter?: string;
39
42
  columns?: {
40
43
  date?: string;
41
44
  amount?: string;
@@ -43,6 +43,12 @@ export const BANK_PRESETS = {
43
43
  dateFormat: 'MDY',
44
44
  },
45
45
  };
46
+ export const SAFE_DELIMITERS = [',', ';', '\t', '|', ' '];
47
+ function validateDelimiter(delimiter) {
48
+ if (!SAFE_DELIMITERS.includes(delimiter)) {
49
+ throw new Error(`Unsafe delimiter "${delimiter}". Allowed delimiters: ${SAFE_DELIMITERS.join(', ')}`);
50
+ }
51
+ }
46
52
  function autoDetectFormat(content) {
47
53
  const preview = Papa.parse(content, {
48
54
  preview: 5,
@@ -85,6 +91,9 @@ function checkTDPattern(rows) {
85
91
  export function parseCSV(content, options = {}) {
86
92
  const errors = [];
87
93
  const warnings = [];
94
+ if (options.delimiter) {
95
+ validateDelimiter(options.delimiter);
96
+ }
88
97
  const MAX_BYTES = options.maxBytes ?? 10 * 1024 * 1024;
89
98
  if (content.length > MAX_BYTES) {
90
99
  throw new Error(`File size exceeds limit of ${Math.round(MAX_BYTES / 1024 / 1024)}MB`);
@@ -119,6 +128,7 @@ export function parseCSV(content, options = {}) {
119
128
  dynamicTyping: false,
120
129
  skipEmptyLines: true,
121
130
  transformHeader: (h) => h.trim(),
131
+ ...(options.delimiter ? { delimiter: options.delimiter } : {}),
122
132
  });
123
133
  if (parsed.errors.length > 0) {
124
134
  for (const err of parsed.errors) {
@@ -231,14 +241,44 @@ export function parseCSV(content, options = {}) {
231
241
  let rawAmount;
232
242
  if (amountCol) {
233
243
  rawAmount = getValue(amountCol)?.trim() ?? '';
234
- amountMilliunits = dollarStringToMilliunits(rawAmount);
244
+ const parsedAmount = parseAmount(rawAmount);
245
+ if (!parsedAmount.valid) {
246
+ errors.push({
247
+ row: rowNum,
248
+ field: 'amount',
249
+ message: parsedAmount.reason ?? `Invalid amount: "${rawAmount}"`,
250
+ rawValue: rawAmount,
251
+ });
252
+ continue;
253
+ }
254
+ amountMilliunits = parsedAmount.valueMilliunits;
235
255
  }
236
256
  else if (debitCol && creditCol) {
237
257
  const debit = getValue(debitCol)?.trim() ?? '';
238
258
  const credit = getValue(creditCol)?.trim() ?? '';
239
259
  rawAmount = debit || credit;
240
- const debitMilliunits = dollarStringToMilliunits(debit);
241
- const creditMilliunits = dollarStringToMilliunits(credit);
260
+ const parsedDebit = parseAmount(debit);
261
+ const parsedCredit = parseAmount(credit);
262
+ if (!parsedDebit.valid && debit) {
263
+ errors.push({
264
+ row: rowNum,
265
+ field: 'amount',
266
+ message: parsedDebit.reason ?? `Invalid debit amount: "${debit}"`,
267
+ rawValue: debit,
268
+ });
269
+ continue;
270
+ }
271
+ if (!parsedCredit.valid && credit) {
272
+ errors.push({
273
+ row: rowNum,
274
+ field: 'amount',
275
+ message: parsedCredit.reason ?? `Invalid credit amount: "${credit}"`,
276
+ rawValue: credit,
277
+ });
278
+ continue;
279
+ }
280
+ const debitMilliunits = parsedDebit.valid ? parsedDebit.valueMilliunits : 0;
281
+ const creditMilliunits = parsedCredit.valid ? parsedCredit.valueMilliunits : 0;
242
282
  if (Math.abs(debitMilliunits) > 0 && Math.abs(creditMilliunits) > 0) {
243
283
  const warning = `Both Debit (${debit}) and Credit (${credit}) have values - using Debit`;
244
284
  rowWarnings.push(warning);
@@ -251,7 +291,13 @@ export function parseCSV(content, options = {}) {
251
291
  amountMilliunits = Math.abs(creditMilliunits);
252
292
  }
253
293
  else {
254
- amountMilliunits = 0;
294
+ errors.push({
295
+ row: rowNum,
296
+ field: 'amount',
297
+ message: 'Missing debit/credit amount',
298
+ rawValue: `${debit}|${credit}`,
299
+ });
300
+ continue;
255
301
  }
256
302
  if (debitMilliunits < 0) {
257
303
  const warning = `Debit column contains negative value (${debit}) - treating as positive debit`;
@@ -262,15 +308,6 @@ export function parseCSV(content, options = {}) {
262
308
  else {
263
309
  continue;
264
310
  }
265
- if (!Number.isFinite(amountMilliunits)) {
266
- errors.push({
267
- row: rowNum,
268
- field: 'amount',
269
- message: `Invalid amount: "${rawAmount}"`,
270
- rawValue: rawAmount,
271
- });
272
- continue;
273
- }
274
311
  const multiplier = options.invertAmounts ? -1 : (preset?.amountMultiplier ?? 1);
275
312
  amountMilliunits *= multiplier;
276
313
  let rawDesc = getValue(descCol)?.trim() ?? '';
@@ -393,9 +430,10 @@ function detectPreset(columns) {
393
430
  }
394
431
  const CURRENCY_SYMBOLS = /[$€£¥]/g;
395
432
  const CURRENCY_CODES = /\b(CAD|USD|EUR|GBP)\b/gi;
396
- function dollarStringToMilliunits(str) {
397
- if (!str)
398
- return 0;
433
+ function parseAmount(str) {
434
+ if (!str || !str.trim()) {
435
+ return { valid: false, valueMilliunits: 0, reason: 'Missing amount value' };
436
+ }
399
437
  let cleaned = str.replace(CURRENCY_SYMBOLS, '').replace(CURRENCY_CODES, '').trim();
400
438
  if (cleaned.startsWith('(') && cleaned.endsWith(')')) {
401
439
  cleaned = '-' + cleaned.slice(1, -1);
@@ -407,7 +445,8 @@ function dollarStringToMilliunits(str) {
407
445
  cleaned = cleaned.replace(/,/g, '');
408
446
  }
409
447
  const dollars = parseFloat(cleaned);
410
- if (!Number.isFinite(dollars))
411
- return 0;
412
- return Math.round(dollars * 1000);
448
+ if (!Number.isFinite(dollars)) {
449
+ return { valid: false, valueMilliunits: 0, reason: `Invalid amount: "${str}"` };
450
+ }
451
+ return { valid: true, valueMilliunits: Math.round(dollars * 1000) };
413
452
  }
@@ -35,6 +35,49 @@ function generateBulkImportId(accountId, date, amountMilli, payee) {
35
35
  const digest = createHash('sha256').update(raw).digest('hex').slice(0, 24);
36
36
  return `YNAB:bulk:${digest}`;
37
37
  }
38
+ function parseISODate(dateStr) {
39
+ if (!dateStr)
40
+ return undefined;
41
+ const d = new Date(dateStr);
42
+ return Number.isNaN(d.getTime()) ? undefined : d;
43
+ }
44
+ function resolveStatementWindow(params, analysisDateRange) {
45
+ const start = parseISODate(params.statement_start_date);
46
+ const end = parseISODate(params.statement_end_date ?? params.statement_date) ??
47
+ undefined;
48
+ if (start || end) {
49
+ const window = {};
50
+ if (start)
51
+ window.start = start;
52
+ if (end)
53
+ window.end = end;
54
+ return window;
55
+ }
56
+ if (analysisDateRange && analysisDateRange.includes(' to ')) {
57
+ const [rawStart, rawEnd] = analysisDateRange.split(' to ').map((part) => part.trim());
58
+ const parsedStart = parseISODate(rawStart);
59
+ const parsedEnd = parseISODate(rawEnd);
60
+ if (parsedStart || parsedEnd) {
61
+ const window = {};
62
+ if (parsedStart)
63
+ window.start = parsedStart;
64
+ if (parsedEnd)
65
+ window.end = parsedEnd;
66
+ return window;
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+ function isWithinStatementWindow(dateStr, window) {
72
+ const date = parseISODate(dateStr);
73
+ if (!date)
74
+ return false;
75
+ if (window.start && date < window.start)
76
+ return false;
77
+ if (window.end && date > window.end)
78
+ return false;
79
+ return true;
80
+ }
38
81
  export async function executeReconciliation(options) {
39
82
  const { analysis, params, ynabAPI, budgetId, accountId, initialAccount, currencyCode } = options;
40
83
  const actions_taken = [];
@@ -84,7 +127,10 @@ export async function executeReconciliation(options) {
84
127
  ? sortByDateDescending(analysis.unmatched_bank)
85
128
  : [];
86
129
  const orderedAutoMatches = sortMatchesByBankDateDescending(analysis.auto_matches);
87
- const orderedUnmatchedYNAB = sortByDateDescending(analysis.unmatched_ynab);
130
+ const statementWindow = resolveStatementWindow(params, analysis.summary.statement_date_range);
131
+ const orderedUnmatchedYNAB = sortByDateDescending(statementWindow
132
+ ? analysis.unmatched_ynab.filter((txn) => isWithinStatementWindow(txn.date, statementWindow))
133
+ : analysis.unmatched_ynab);
88
134
  let bulkOperationDetails;
89
135
  if (params.auto_create_transactions && !balanceAligned) {
90
136
  const buildPreparedEntry = (bankTxn) => {