@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
|
@@ -69,7 +69,7 @@ export declare class YNABMCPServer {
|
|
|
69
69
|
}[];
|
|
70
70
|
}>;
|
|
71
71
|
handleListResources(): Promise<{
|
|
72
|
-
resources: import("
|
|
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
|
-
|
|
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:
|
|
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 {};
|
package/dist/server/resources.js
CHANGED
|
@@ -1,49 +1,60 @@
|
|
|
1
|
+
import { CacheManager, CACHE_TTLS } from './cacheManager.js';
|
|
1
2
|
const defaultResourceHandlers = {
|
|
2
|
-
'ynab://budgets': async (uri, { ynabAPI, responseFormatter }) => {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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 (
|
|
81
|
-
|
|
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
|
}
|
|
@@ -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
|
};
|
|
@@ -2,4 +2,8 @@ import type * as ynab from 'ynab';
|
|
|
2
2
|
import { type ParseCSVOptions, type CSVParseResult } from './csvParser.js';
|
|
3
3
|
import { type MatchingConfig } from './matcher.js';
|
|
4
4
|
import type { ReconciliationAnalysis } from './types.js';
|
|
5
|
-
export declare function analyzeReconciliation(csvContentOrParsed: string | CSVParseResult, _csvFilePath: string | undefined, ynabTransactions: ynab.TransactionDetail[], statementBalance: number, config?: MatchingConfig, currency?: string, accountId?: string, budgetId?: string, invertBankAmounts?: boolean, csvOptions?: ParseCSVOptions
|
|
5
|
+
export declare function analyzeReconciliation(csvContentOrParsed: string | CSVParseResult, _csvFilePath: string | undefined, ynabTransactions: ynab.TransactionDetail[], statementBalance: number, config?: MatchingConfig, currency?: string, accountId?: string, budgetId?: string, invertBankAmounts?: boolean, csvOptions?: ParseCSVOptions, accountSnapshot?: {
|
|
6
|
+
balance?: number;
|
|
7
|
+
cleared_balance?: number;
|
|
8
|
+
uncleared_balance?: number;
|
|
9
|
+
}): ReconciliationAnalysis;
|
|
@@ -29,20 +29,22 @@ function mapToTransactionMatch(result) {
|
|
|
29
29
|
}
|
|
30
30
|
return match;
|
|
31
31
|
}
|
|
32
|
-
function calculateBalances(ynabTransactions, statementBalanceDecimal, currency) {
|
|
33
|
-
let
|
|
34
|
-
let
|
|
32
|
+
function calculateBalances(ynabTransactions, statementBalanceDecimal, currency, accountSnapshot) {
|
|
33
|
+
let computedCleared = 0;
|
|
34
|
+
let computedUncleared = 0;
|
|
35
35
|
for (const txn of ynabTransactions) {
|
|
36
36
|
const amount = txn.amount;
|
|
37
37
|
if (txn.cleared === 'cleared' || txn.cleared === 'reconciled') {
|
|
38
|
-
|
|
38
|
+
computedCleared += amount;
|
|
39
39
|
}
|
|
40
40
|
else {
|
|
41
|
-
|
|
41
|
+
computedUncleared += amount;
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
|
+
const clearedBalance = accountSnapshot?.cleared_balance ?? computedCleared;
|
|
45
|
+
const unclearedBalance = accountSnapshot?.uncleared_balance ?? computedUncleared;
|
|
46
|
+
const totalBalance = accountSnapshot?.balance ?? clearedBalance + unclearedBalance;
|
|
44
47
|
const statementBalanceMilli = Math.round(statementBalanceDecimal * 1000);
|
|
45
|
-
const totalBalance = clearedBalance + unclearedBalance;
|
|
46
48
|
const discrepancy = clearedBalance - statementBalanceMilli;
|
|
47
49
|
return {
|
|
48
50
|
current_cleared: toMoneyValue(clearedBalance, currency),
|
|
@@ -220,7 +222,7 @@ function detectInsights(unmatchedBank, _summary, balances, currency, csvErrors =
|
|
|
220
222
|
}
|
|
221
223
|
return insights.slice(0, 5);
|
|
222
224
|
}
|
|
223
|
-
export function analyzeReconciliation(csvContentOrParsed, _csvFilePath, ynabTransactions, statementBalance, config = DEFAULT_CONFIG, currency = 'USD', accountId, budgetId, invertBankAmounts = false, csvOptions) {
|
|
225
|
+
export function analyzeReconciliation(csvContentOrParsed, _csvFilePath, ynabTransactions, statementBalance, config = DEFAULT_CONFIG, currency = 'USD', accountId, budgetId, invertBankAmounts = false, csvOptions, accountSnapshot) {
|
|
224
226
|
let parseResult;
|
|
225
227
|
if (typeof csvContentOrParsed === 'string') {
|
|
226
228
|
parseResult = parseCSV(csvContentOrParsed, {
|
|
@@ -254,7 +256,7 @@ export function analyzeReconciliation(csvContentOrParsed, _csvFilePath, ynabTran
|
|
|
254
256
|
matchedYnabIds.add(m.ynabTransaction.id);
|
|
255
257
|
});
|
|
256
258
|
const unmatchedYNAB = newYNABTransactions.filter((t) => !matchedYnabIds.has(t.id));
|
|
257
|
-
const balances = calculateBalances(newYNABTransactions, statementBalance, currency);
|
|
259
|
+
const balances = calculateBalances(newYNABTransactions, statementBalance, currency, accountSnapshot);
|
|
258
260
|
const summary = generateSummary(matches.map((m) => m.bankTransaction), newYNABTransactions, autoMatches, suggestedMatches, unmatchedBank, unmatchedYNAB, balances);
|
|
259
261
|
const nextSteps = generateNextSteps(summary);
|
|
260
262
|
const insights = detectInsights(unmatchedBank, summary, balances, currency, csvParseErrors, csvParseWarnings);
|
|
@@ -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
|
-
|
|
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
|
|
241
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|