@dizzlkheinz/ynab-mcpb 0.15.0 â 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.
- package/CHANGELOG.md +36 -0
- package/dist/bundle/index.cjs +50 -49
- 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/analyzer.d.ts +5 -1
- package/dist/tools/reconciliation/analyzer.js +10 -8
- 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/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/reconcileAdapter.ts +2 -0
- package/src/tools/reconciliation/__tests__/reportFormatter.test.ts +23 -23
- package/src/tools/reconciliation/analyzer.ts +18 -6
- package/src/tools/reconciliation/csvParser.ts +84 -18
- package/src/tools/reconciliation/executor.ts +58 -1
- package/src/tools/reconciliation/index.ts +112 -61
- package/src/tools/reconciliation/reportFormatter.ts +55 -37
package/src/server/resources.ts
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resources module for YNAB MCP Server
|
|
3
3
|
*
|
|
4
|
-
* Handles MCP resource definitions and handlers.
|
|
4
|
+
* Handles MCP resource definitions, templates, and handlers.
|
|
5
5
|
* Extracted from YNABMCPServer to provide focused, testable resource management.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import type * as ynab from 'ynab';
|
|
9
|
+
import {
|
|
10
|
+
ResourceTemplate as MCPResourceTemplate,
|
|
11
|
+
Resource as MCPResource,
|
|
12
|
+
ResourceContents,
|
|
13
|
+
} from '@modelcontextprotocol/sdk/types.js';
|
|
14
|
+
import { CacheManager, CACHE_TTLS } from './cacheManager.js';
|
|
9
15
|
|
|
10
16
|
/**
|
|
11
17
|
* Response formatter interface to avoid direct dependency on concrete implementation
|
|
@@ -20,13 +26,16 @@ interface ResponseFormatter {
|
|
|
20
26
|
export type ResourceHandler = (
|
|
21
27
|
uri: string,
|
|
22
28
|
dependencies: ResourceDependencies,
|
|
23
|
-
) => Promise<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
) => Promise<ResourceContents[]>;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Template handler function signature
|
|
33
|
+
*/
|
|
34
|
+
export type TemplateHandler = (
|
|
35
|
+
uri: string,
|
|
36
|
+
params: Record<string, string>,
|
|
37
|
+
dependencies: ResourceDependencies,
|
|
38
|
+
) => Promise<ResourceContents[]>;
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* Resource definition structure
|
|
@@ -38,64 +47,82 @@ export interface ResourceDefinition {
|
|
|
38
47
|
mimeType: string;
|
|
39
48
|
}
|
|
40
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Resource Template definition structure
|
|
52
|
+
*/
|
|
53
|
+
export interface ResourceTemplateDefinition extends MCPResourceTemplate {
|
|
54
|
+
handler: TemplateHandler;
|
|
55
|
+
}
|
|
56
|
+
|
|
41
57
|
/**
|
|
42
58
|
* Injectable dependencies for resource handlers
|
|
43
59
|
*/
|
|
44
60
|
export interface ResourceDependencies {
|
|
45
61
|
ynabAPI: ynab.API;
|
|
46
62
|
responseFormatter: ResponseFormatter;
|
|
63
|
+
cacheManager: CacheManager;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
67
|
* Default resource handlers
|
|
51
68
|
*/
|
|
52
69
|
const defaultResourceHandlers: Record<string, ResourceHandler> = {
|
|
53
|
-
'ynab://budgets': async (uri, { ynabAPI, responseFormatter }) => {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
'ynab://budgets': async (uri, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
71
|
+
const cacheKey = CacheManager.generateKey('resources', 'budgets', 'list');
|
|
72
|
+
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
73
|
+
ttl: CACHE_TTLS.BUDGETS,
|
|
74
|
+
loader: async () => {
|
|
75
|
+
try {
|
|
76
|
+
const response = await ynabAPI.budgets.getBudgets();
|
|
77
|
+
const budgets = response.data.budgets.map((budget) => ({
|
|
78
|
+
id: budget.id,
|
|
79
|
+
name: budget.name,
|
|
80
|
+
last_modified_on: budget.last_modified_on,
|
|
81
|
+
first_month: budget.first_month,
|
|
82
|
+
last_month: budget.last_month,
|
|
83
|
+
currency_format: budget.currency_format,
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
return [
|
|
87
|
+
{
|
|
88
|
+
uri: uri,
|
|
89
|
+
mimeType: 'application/json',
|
|
90
|
+
text: responseFormatter.format({ budgets }),
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
} catch (error) {
|
|
94
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
95
|
+
throw new Error(`Failed to fetch budgets: ${message}`);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
});
|
|
77
99
|
},
|
|
78
100
|
|
|
79
|
-
'ynab://user': async (uri, { ynabAPI, responseFormatter }) => {
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
'ynab://user': async (uri, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
102
|
+
const cacheKey = CacheManager.generateKey('resources', 'user');
|
|
103
|
+
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
104
|
+
ttl: CACHE_TTLS.USER_INFO,
|
|
105
|
+
loader: async () => {
|
|
106
|
+
try {
|
|
107
|
+
const response = await ynabAPI.user.getUser();
|
|
108
|
+
const userInfo = response.data.user;
|
|
109
|
+
const user = {
|
|
110
|
+
id: userInfo.id,
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
{
|
|
115
|
+
uri: uri,
|
|
116
|
+
mimeType: 'application/json',
|
|
117
|
+
text: responseFormatter.format({ user }),
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
throw new Error(`Failed to fetch user info: ${message}`);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
});
|
|
99
126
|
},
|
|
100
127
|
};
|
|
101
128
|
|
|
@@ -117,6 +144,109 @@ const defaultResourceDefinitions: ResourceDefinition[] = [
|
|
|
117
144
|
},
|
|
118
145
|
];
|
|
119
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Default resource templates
|
|
149
|
+
*/
|
|
150
|
+
const defaultResourceTemplates: ResourceTemplateDefinition[] = [
|
|
151
|
+
{
|
|
152
|
+
uriTemplate: 'ynab://budgets/{budget_id}',
|
|
153
|
+
name: 'Budget Details',
|
|
154
|
+
description: 'Detailed information for a specific budget',
|
|
155
|
+
mimeType: 'application/json',
|
|
156
|
+
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
157
|
+
const budget_id = params['budget_id'];
|
|
158
|
+
if (!budget_id) throw new Error('Missing budget_id parameter');
|
|
159
|
+
const cacheKey = CacheManager.generateKey('resources', 'budgets', 'get', budget_id);
|
|
160
|
+
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
161
|
+
ttl: CACHE_TTLS.BUDGETS,
|
|
162
|
+
loader: async () => {
|
|
163
|
+
try {
|
|
164
|
+
const response = await ynabAPI.budgets.getBudgetById(budget_id);
|
|
165
|
+
return [
|
|
166
|
+
{
|
|
167
|
+
uri,
|
|
168
|
+
mimeType: 'application/json',
|
|
169
|
+
text: responseFormatter.format(response.data.budget),
|
|
170
|
+
},
|
|
171
|
+
];
|
|
172
|
+
} catch (error) {
|
|
173
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
174
|
+
throw new Error(`Failed to fetch budget ${budget_id}: ${message}`);
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
{
|
|
181
|
+
uriTemplate: 'ynab://budgets/{budget_id}/accounts',
|
|
182
|
+
name: 'Budget Accounts',
|
|
183
|
+
description: 'List of accounts for a specific budget',
|
|
184
|
+
mimeType: 'application/json',
|
|
185
|
+
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
186
|
+
const budget_id = params['budget_id'];
|
|
187
|
+
if (!budget_id) throw new Error('Missing budget_id parameter');
|
|
188
|
+
const cacheKey = CacheManager.generateKey('resources', 'accounts', 'list', budget_id);
|
|
189
|
+
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
190
|
+
ttl: CACHE_TTLS.ACCOUNTS,
|
|
191
|
+
loader: async () => {
|
|
192
|
+
try {
|
|
193
|
+
const response = await ynabAPI.accounts.getAccounts(budget_id);
|
|
194
|
+
return [
|
|
195
|
+
{
|
|
196
|
+
uri,
|
|
197
|
+
mimeType: 'application/json',
|
|
198
|
+
text: responseFormatter.format(response.data.accounts),
|
|
199
|
+
},
|
|
200
|
+
];
|
|
201
|
+
} catch (error) {
|
|
202
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
203
|
+
throw new Error(`Failed to fetch accounts for budget ${budget_id}: ${message}`);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
},
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
uriTemplate: 'ynab://budgets/{budget_id}/accounts/{account_id}',
|
|
211
|
+
name: 'Account Details',
|
|
212
|
+
description: 'Detailed information for a specific account within a budget',
|
|
213
|
+
mimeType: 'application/json',
|
|
214
|
+
handler: async (uri, params, { ynabAPI, responseFormatter, cacheManager }) => {
|
|
215
|
+
const budget_id = params['budget_id'];
|
|
216
|
+
const account_id = params['account_id'];
|
|
217
|
+
if (!budget_id) throw new Error('Missing budget_id parameter');
|
|
218
|
+
if (!account_id) throw new Error('Missing account_id parameter');
|
|
219
|
+
const cacheKey = CacheManager.generateKey(
|
|
220
|
+
'resources',
|
|
221
|
+
'accounts',
|
|
222
|
+
'get',
|
|
223
|
+
budget_id,
|
|
224
|
+
account_id,
|
|
225
|
+
);
|
|
226
|
+
return cacheManager.wrap<ResourceContents[]>(cacheKey, {
|
|
227
|
+
ttl: CACHE_TTLS.ACCOUNTS,
|
|
228
|
+
loader: async () => {
|
|
229
|
+
try {
|
|
230
|
+
const response = await ynabAPI.accounts.getAccountById(budget_id, account_id);
|
|
231
|
+
return [
|
|
232
|
+
{
|
|
233
|
+
uri,
|
|
234
|
+
mimeType: 'application/json',
|
|
235
|
+
text: responseFormatter.format(response.data.account),
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
} catch (error) {
|
|
239
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
240
|
+
throw new Error(
|
|
241
|
+
`Failed to fetch account ${account_id} in budget ${budget_id}: ${message}`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
|
|
120
250
|
/**
|
|
121
251
|
* ResourceManager class that handles resource registration and request handling
|
|
122
252
|
*/
|
|
@@ -124,11 +254,14 @@ export class ResourceManager {
|
|
|
124
254
|
private dependencies: ResourceDependencies;
|
|
125
255
|
private resourceHandlers: Record<string, ResourceHandler>;
|
|
126
256
|
private resourceDefinitions: ResourceDefinition[];
|
|
257
|
+
private resourceTemplates: ResourceTemplateDefinition[];
|
|
127
258
|
|
|
128
259
|
constructor(dependencies: ResourceDependencies) {
|
|
129
260
|
this.dependencies = dependencies;
|
|
130
261
|
this.resourceHandlers = { ...defaultResourceHandlers };
|
|
131
262
|
this.resourceDefinitions = [...defaultResourceDefinitions];
|
|
263
|
+
this.resourceTemplates = [];
|
|
264
|
+
defaultResourceTemplates.forEach((template) => this.registerTemplate(template));
|
|
132
265
|
}
|
|
133
266
|
|
|
134
267
|
/**
|
|
@@ -139,12 +272,39 @@ export class ResourceManager {
|
|
|
139
272
|
this.resourceHandlers[definition.uri] = handler;
|
|
140
273
|
}
|
|
141
274
|
|
|
275
|
+
/**
|
|
276
|
+
* Register a new resource template
|
|
277
|
+
*/
|
|
278
|
+
registerTemplate(definition: ResourceTemplateDefinition): void {
|
|
279
|
+
this.validateTemplateDefinition(definition);
|
|
280
|
+
this.resourceTemplates.push(definition);
|
|
281
|
+
}
|
|
282
|
+
|
|
142
283
|
/**
|
|
143
284
|
* Returns list of available resources for MCP resource listing
|
|
144
285
|
*/
|
|
145
|
-
listResources(): { resources:
|
|
286
|
+
listResources(): { resources: MCPResource[] } {
|
|
287
|
+
return {
|
|
288
|
+
resources: this.resourceDefinitions.map((r) => ({
|
|
289
|
+
uri: r.uri,
|
|
290
|
+
name: r.name,
|
|
291
|
+
description: r.description,
|
|
292
|
+
mimeType: r.mimeType,
|
|
293
|
+
})),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Returns list of available resource templates
|
|
299
|
+
*/
|
|
300
|
+
listResourceTemplates(): { resourceTemplates: MCPResourceTemplate[] } {
|
|
146
301
|
return {
|
|
147
|
-
|
|
302
|
+
resourceTemplates: this.resourceTemplates.map((t) => ({
|
|
303
|
+
uriTemplate: t.uriTemplate,
|
|
304
|
+
name: t.name,
|
|
305
|
+
description: t.description,
|
|
306
|
+
mimeType: t.mimeType,
|
|
307
|
+
})),
|
|
148
308
|
};
|
|
149
309
|
}
|
|
150
310
|
|
|
@@ -152,17 +312,102 @@ export class ResourceManager {
|
|
|
152
312
|
* Handles resource read requests
|
|
153
313
|
*/
|
|
154
314
|
async readResource(uri: string): Promise<{
|
|
155
|
-
contents:
|
|
156
|
-
uri: string;
|
|
157
|
-
mimeType: string;
|
|
158
|
-
text: string;
|
|
159
|
-
}[];
|
|
315
|
+
contents: ResourceContents[];
|
|
160
316
|
}> {
|
|
317
|
+
// 1. Try exact match first
|
|
161
318
|
const handler = this.resourceHandlers[uri];
|
|
162
|
-
if (
|
|
163
|
-
|
|
319
|
+
if (handler) {
|
|
320
|
+
return { contents: await handler(uri, this.dependencies) };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// 2. Try template matching
|
|
324
|
+
for (const template of this.resourceTemplates) {
|
|
325
|
+
const params = this.matchTemplate(template.uriTemplate, uri);
|
|
326
|
+
if (params) {
|
|
327
|
+
try {
|
|
328
|
+
return { contents: await template.handler(uri, params, this.dependencies) };
|
|
329
|
+
} catch (error) {
|
|
330
|
+
throw new Error(
|
|
331
|
+
`Failed to resolve template resource ${uri}: ${error instanceof Error ? error.message : String(error)}`,
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
throw new Error(`Unknown resource: ${uri}`);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Simple URI template matcher
|
|
342
|
+
* Supports {param} syntax with validation to prevent regex injection
|
|
343
|
+
*
|
|
344
|
+
* @param template - URI template with {param} placeholders
|
|
345
|
+
* @param uri - Actual URI to match against template
|
|
346
|
+
* @returns Object with extracted parameters or null if no match
|
|
347
|
+
*/
|
|
348
|
+
private matchTemplate(template: string, uri: string): Record<string, string> | null {
|
|
349
|
+
// Validate template format (only allow safe characters and template syntax)
|
|
350
|
+
if (!/^[a-z0-9:/\-_{}]+$/i.test(template)) {
|
|
351
|
+
throw new Error('Invalid template format: contains unsafe characters');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Extract and validate parameter names
|
|
355
|
+
const paramNames: string[] = [];
|
|
356
|
+
const regexPattern = template
|
|
357
|
+
.replace(/[.*+?^$()|[\]\\]/g, '\\$&') // Escape special regex chars
|
|
358
|
+
.replace(/{([a-z_][a-z0-9_]*)}/gi, (_, name) => {
|
|
359
|
+
paramNames.push(name);
|
|
360
|
+
return '([^/]+)'; // Capture group for parameter value
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// Templates are validated at registration and come from trusted internal sources.
|
|
364
|
+
// If external template registration is introduced, consider a ReDoS-safe matcher.
|
|
365
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
366
|
+
const match = uri.match(regex);
|
|
367
|
+
|
|
368
|
+
if (match) {
|
|
369
|
+
const result: Record<string, string> = {};
|
|
370
|
+
paramNames.forEach((name, i) => {
|
|
371
|
+
const value = match[i + 1];
|
|
372
|
+
if (value) {
|
|
373
|
+
// Validate parameter values don't contain path traversal or invalid chars
|
|
374
|
+
if (value.includes('..') || value.includes('\\')) {
|
|
375
|
+
throw new Error(`Invalid parameter value: ${name}=${value}`);
|
|
376
|
+
}
|
|
377
|
+
result[name] = value;
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
return result;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Validate template format and parameter names at registration time
|
|
388
|
+
*/
|
|
389
|
+
private validateTemplateDefinition(definition: ResourceTemplateDefinition): void {
|
|
390
|
+
const { uriTemplate } = definition;
|
|
391
|
+
if (!/^[a-z0-9:/\-_{}]+$/i.test(uriTemplate)) {
|
|
392
|
+
throw new Error(`Invalid template format: contains unsafe characters (${uriTemplate})`);
|
|
164
393
|
}
|
|
165
394
|
|
|
166
|
-
|
|
395
|
+
const placeholderPattern = /{([^}]+)}/g;
|
|
396
|
+
const paramNames: string[] = [];
|
|
397
|
+
let match: RegExpExecArray | null;
|
|
398
|
+
while ((match = placeholderPattern.exec(uriTemplate)) !== null) {
|
|
399
|
+
const paramName = match[1] ?? '';
|
|
400
|
+
if (!/^[a-z_][a-z0-9_]*$/i.test(paramName)) {
|
|
401
|
+
throw new Error(
|
|
402
|
+
`Invalid template parameter name '${paramName}' in template ${uriTemplate}`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
paramNames.push(paramName);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const uniqueNames = new Set(paramNames);
|
|
409
|
+
if (uniqueNames.size !== paramNames.length) {
|
|
410
|
+
throw new Error(`Duplicate parameter names detected in template ${uriTemplate}`);
|
|
411
|
+
}
|
|
167
412
|
}
|
|
168
413
|
}
|
|
@@ -22,6 +22,7 @@ interface AdapterOptions {
|
|
|
22
22
|
currencyCode?: string;
|
|
23
23
|
csvFormat?: CsvFormatPayload;
|
|
24
24
|
auditMetadata?: Record<string, unknown>;
|
|
25
|
+
notes?: string[];
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
interface DualChannelPayload {
|
|
@@ -258,6 +259,7 @@ const buildHumanNarrative = (
|
|
|
258
259
|
includeDetailedMatches: false,
|
|
259
260
|
maxUnmatchedToShow: 5,
|
|
260
261
|
maxInsightsToShow: 3,
|
|
262
|
+
notes: options.notes,
|
|
261
263
|
};
|
|
262
264
|
|
|
263
265
|
return formatHumanReadableReport(analysis, formatterOptions, execution);
|
|
@@ -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', () => {
|
|
@@ -61,22 +61,28 @@ function calculateBalances(
|
|
|
61
61
|
ynabTransactions: YNABTransaction[],
|
|
62
62
|
statementBalanceDecimal: number,
|
|
63
63
|
currency: string,
|
|
64
|
+
accountSnapshot?: { balance?: number; cleared_balance?: number; uncleared_balance?: number },
|
|
64
65
|
): BalanceInfo {
|
|
65
|
-
|
|
66
|
-
|
|
66
|
+
// Compute from the fetched transactions, but prefer the authoritative account snapshot
|
|
67
|
+
// because we usually fetch a limited date window.
|
|
68
|
+
let computedCleared = 0;
|
|
69
|
+
let computedUncleared = 0;
|
|
67
70
|
|
|
68
71
|
for (const txn of ynabTransactions) {
|
|
69
72
|
const amount = txn.amount; // Milliunits
|
|
70
73
|
|
|
71
74
|
if (txn.cleared === 'cleared' || txn.cleared === 'reconciled') {
|
|
72
|
-
|
|
75
|
+
computedCleared += amount;
|
|
73
76
|
} else {
|
|
74
|
-
|
|
77
|
+
computedUncleared += amount;
|
|
75
78
|
}
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
const clearedBalance = accountSnapshot?.cleared_balance ?? computedCleared;
|
|
82
|
+
const unclearedBalance = accountSnapshot?.uncleared_balance ?? computedUncleared;
|
|
83
|
+
const totalBalance = accountSnapshot?.balance ?? clearedBalance + unclearedBalance;
|
|
84
|
+
|
|
78
85
|
const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
|
|
79
|
-
const totalBalance = clearedBalance + unclearedBalance;
|
|
80
86
|
const discrepancy = clearedBalance - statementBalanceMilli;
|
|
81
87
|
|
|
82
88
|
return {
|
|
@@ -342,6 +348,7 @@ export function analyzeReconciliation(
|
|
|
342
348
|
budgetId?: string,
|
|
343
349
|
invertBankAmounts: boolean = false,
|
|
344
350
|
csvOptions?: ParseCSVOptions,
|
|
351
|
+
accountSnapshot?: { balance?: number; cleared_balance?: number; uncleared_balance?: number },
|
|
345
352
|
): ReconciliationAnalysis {
|
|
346
353
|
// Step 1: Parse bank CSV using new Parser (or use provided result)
|
|
347
354
|
let parseResult: CSVParseResult;
|
|
@@ -398,7 +405,12 @@ export function analyzeReconciliation(
|
|
|
398
405
|
const unmatchedYNAB = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
|
|
399
406
|
|
|
400
407
|
// Step 6: Calculate balances
|
|
401
|
-
const balances = calculateBalances(
|
|
408
|
+
const balances = calculateBalances(
|
|
409
|
+
newYNABTransactions,
|
|
410
|
+
statementBalance,
|
|
411
|
+
currency,
|
|
412
|
+
accountSnapshot,
|
|
413
|
+
);
|
|
402
414
|
|
|
403
415
|
// Step 7: Generate summary
|
|
404
416
|
const summary = generateSummary(
|