paisa-mcp 0.0.21 → 0.0.22
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/dist/index.js +2677 -1155
- package/dist/index.js.map +39 -26
- package/package.json +11 -12
package/dist/index.js
CHANGED
|
@@ -38157,6 +38157,21 @@ axios.default = axios;
|
|
|
38157
38157
|
var axios_default = axios;
|
|
38158
38158
|
|
|
38159
38159
|
// src/api-client.ts
|
|
38160
|
+
class ApiError extends Error {
|
|
38161
|
+
status;
|
|
38162
|
+
body;
|
|
38163
|
+
method;
|
|
38164
|
+
path;
|
|
38165
|
+
constructor(message, status, body, method, path) {
|
|
38166
|
+
super(message);
|
|
38167
|
+
this.status = status;
|
|
38168
|
+
this.body = body;
|
|
38169
|
+
this.method = method;
|
|
38170
|
+
this.path = path;
|
|
38171
|
+
this.name = "ApiError";
|
|
38172
|
+
}
|
|
38173
|
+
}
|
|
38174
|
+
|
|
38160
38175
|
class PaisaApiClient {
|
|
38161
38176
|
http;
|
|
38162
38177
|
constructor(baseUrl, token) {
|
|
@@ -38169,12 +38184,12 @@ class PaisaApiClient {
|
|
|
38169
38184
|
}
|
|
38170
38185
|
});
|
|
38171
38186
|
this.http.interceptors.response.use((res) => res, (err) => {
|
|
38172
|
-
const status = err.response?.status
|
|
38187
|
+
const status = err.response?.status;
|
|
38173
38188
|
const body = err.response?.data;
|
|
38174
38189
|
const detail = typeof body === "string" ? body : JSON.stringify(body ?? "");
|
|
38175
38190
|
const method = err.config?.method?.toUpperCase() ?? "?";
|
|
38176
38191
|
const url2 = err.config?.url ?? "?";
|
|
38177
|
-
throw new
|
|
38192
|
+
throw new ApiError(`API ${method} ${url2} failed (${status ?? "?"}): ${detail}`, status, body, method, url2);
|
|
38178
38193
|
});
|
|
38179
38194
|
}
|
|
38180
38195
|
async get(path, params) {
|
|
@@ -38298,6 +38313,58 @@ async function encryptTransactionFields(items, key) {
|
|
|
38298
38313
|
await encryptObjectFields(item, ENCRYPTED_TXN_FIELDS, key);
|
|
38299
38314
|
}
|
|
38300
38315
|
}
|
|
38316
|
+
var VERIFIER_PLAINTEXT = "paisa-verify-v1";
|
|
38317
|
+
async function checkVerifier(key, encryptedVerifier) {
|
|
38318
|
+
try {
|
|
38319
|
+
const decrypted = await decryptField(encryptedVerifier, key);
|
|
38320
|
+
return decrypted === VERIFIER_PLAINTEXT;
|
|
38321
|
+
} catch {
|
|
38322
|
+
return false;
|
|
38323
|
+
}
|
|
38324
|
+
}
|
|
38325
|
+
|
|
38326
|
+
// src/lib/crypto-init.ts
|
|
38327
|
+
async function initCrypto(client, pin, userId) {
|
|
38328
|
+
if (pin && !userId)
|
|
38329
|
+
throw new Error("PAISA_USER_ID is required when PAISA_PIN is set.");
|
|
38330
|
+
let meta2;
|
|
38331
|
+
try {
|
|
38332
|
+
meta2 = await client.get("/api/auth-crypto");
|
|
38333
|
+
} catch (err) {
|
|
38334
|
+
throw new Error(`Could not load encryption settings from /api/auth-crypto, refusing to start: ${err instanceof Error ? err.message : String(err)}`);
|
|
38335
|
+
}
|
|
38336
|
+
const mode = meta2.encryptionMode ?? "standard";
|
|
38337
|
+
if (mode === "private" && !pin) {
|
|
38338
|
+
throw new Error("This account is in private (encrypted) mode but PAISA_PIN is not set. " + "Set PAISA_PIN and PAISA_USER_ID; refusing to write plaintext.");
|
|
38339
|
+
}
|
|
38340
|
+
if (mode !== "private") {
|
|
38341
|
+
if (pin) {
|
|
38342
|
+
throw new Error("PAISA_PIN is set but this account is not in private mode. " + "Unset PAISA_PIN and PAISA_USER_ID; refusing to write ciphertext into a plaintext account.");
|
|
38343
|
+
}
|
|
38344
|
+
return;
|
|
38345
|
+
}
|
|
38346
|
+
const pinValue = pin;
|
|
38347
|
+
if ((meta2.kdfVersion ?? 1) >= 2) {
|
|
38348
|
+
if (!meta2.kdfSalt || !meta2.wrappedDek) {
|
|
38349
|
+
throw new Error("Server reports kdfVersion 2 but is missing kdfSalt/wrappedDek.");
|
|
38350
|
+
}
|
|
38351
|
+
try {
|
|
38352
|
+
const kek = await deriveKEK(pinValue, meta2.kdfSalt);
|
|
38353
|
+
return { key: await unwrapDEK(meta2.wrappedDek, kek) };
|
|
38354
|
+
} catch {
|
|
38355
|
+
throw new Error("Incorrect PAISA_PIN.");
|
|
38356
|
+
}
|
|
38357
|
+
}
|
|
38358
|
+
const uid = userId;
|
|
38359
|
+
const key = await deriveKey(uid, pinValue, uid);
|
|
38360
|
+
if (!meta2.encryptedVerifier) {
|
|
38361
|
+
throw new Error("Server has no PIN verifier stored for this private-mode account; re-run PIN setup in the web app.");
|
|
38362
|
+
}
|
|
38363
|
+
if (!await checkVerifier(key, meta2.encryptedVerifier)) {
|
|
38364
|
+
throw new Error("Incorrect PAISA_PIN (or PAISA_USER_ID).");
|
|
38365
|
+
}
|
|
38366
|
+
return { key };
|
|
38367
|
+
}
|
|
38301
38368
|
|
|
38302
38369
|
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
|
|
38303
38370
|
var exports_external = {};
|
|
@@ -42311,13 +42378,17 @@ function registerAlertRuleTools(server, client) {
|
|
|
42311
42378
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42312
42379
|
});
|
|
42313
42380
|
server.registerTool("set_alert_rule", {
|
|
42314
|
-
description: "
|
|
42381
|
+
description: "Update an existing alert rule's enabled flag and/or threshold.",
|
|
42315
42382
|
inputSchema: {
|
|
42316
42383
|
type: exports_external.string().describe("Rule type (e.g. budget_threshold, spending_spike)"),
|
|
42317
|
-
|
|
42384
|
+
enabled: exports_external.boolean().optional().describe("Turn the rule on or off"),
|
|
42385
|
+
threshold: exports_external.string().regex(/^\d{1,4}(\.\d{1,2})?$/).optional().nullable().describe('Threshold as a decimal string, e.g. "80" or "80.50"')
|
|
42318
42386
|
}
|
|
42319
|
-
}, async ({ type,
|
|
42320
|
-
const data = await client.
|
|
42387
|
+
}, async ({ type, enabled, threshold }) => {
|
|
42388
|
+
const data = await client.patch(`/api/alerts/rules/${type}`, {
|
|
42389
|
+
...enabled !== undefined ? { enabled } : {},
|
|
42390
|
+
...threshold !== undefined ? { threshold } : {}
|
|
42391
|
+
});
|
|
42321
42392
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42322
42393
|
});
|
|
42323
42394
|
}
|
|
@@ -42352,7 +42423,7 @@ function registerAlertTools(server, client) {
|
|
|
42352
42423
|
description: "Dismiss a specific alert.",
|
|
42353
42424
|
inputSchema: { id: exports_external.string().describe("Alert UUID") }
|
|
42354
42425
|
}, async ({ id }) => {
|
|
42355
|
-
const data = await client.
|
|
42426
|
+
const data = await client.patch(`/api/alerts/${id}/dismiss`);
|
|
42356
42427
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42357
42428
|
});
|
|
42358
42429
|
}
|
|
@@ -42392,17 +42463,9 @@ async function decryptAnalytics(data, key) {
|
|
|
42392
42463
|
}
|
|
42393
42464
|
function registerAnalyticsTools(server, client, crypto3) {
|
|
42394
42465
|
server.registerTool("get_analytics", {
|
|
42395
|
-
description: "Analytics reports. Select one via `report`:\n" + "-
|
|
42466
|
+
description: "Analytics reports. Select one via `report`:\n" + "- spending_trends: Monthly spending trends by category type over the last N months (per-month rows), plus category breakdown and budget vs actual. Optional `months` (default: 6). For a specific month, read that month's row.\n" + "- category_analytics: Detailed spending breakdown for a specific category over time. Requires `slug`; optional `months`.\n" + "- merchant_analytics: Transaction history and spend total for a specific merchant. Requires `id`; optional `months`.",
|
|
42396
42467
|
inputSchema: {
|
|
42397
|
-
report: exports_external.enum([
|
|
42398
|
-
"monthly_summary",
|
|
42399
|
-
"spending_trends",
|
|
42400
|
-
"category_analytics",
|
|
42401
|
-
"merchant_analytics"
|
|
42402
|
-
]),
|
|
42403
|
-
month: exports_external.number().min(1).max(12).optional().describe("monthly_summary: target month (1-12)"),
|
|
42404
|
-
year: exports_external.number().optional().describe("monthly_summary: target year"),
|
|
42405
|
-
owner: exports_external.string().optional().describe("monthly_summary/spending_trends: filter by owner slug, or omit for all"),
|
|
42468
|
+
report: exports_external.enum(["spending_trends", "category_analytics", "merchant_analytics"]),
|
|
42406
42469
|
months: exports_external.number().optional().describe("spending_trends/category_analytics/merchant_analytics: months to look back (spending_trends default: 6)"),
|
|
42407
42470
|
slug: exports_external.string().optional().describe("category_analytics: category slug"),
|
|
42408
42471
|
id: exports_external.string().optional().describe("merchant_analytics: merchant UUID")
|
|
@@ -42410,18 +42473,8 @@ function registerAnalyticsTools(server, client, crypto3) {
|
|
|
42410
42473
|
}, async (params) => {
|
|
42411
42474
|
let data;
|
|
42412
42475
|
switch (params.report) {
|
|
42413
|
-
case "monthly_summary":
|
|
42414
|
-
data = await client.get("/api/analytics", {
|
|
42415
|
-
month: params.month,
|
|
42416
|
-
year: params.year,
|
|
42417
|
-
...params.owner ? { owner: params.owner } : {}
|
|
42418
|
-
});
|
|
42419
|
-
break;
|
|
42420
42476
|
case "spending_trends":
|
|
42421
|
-
data = await client.get("/api/analytics", {
|
|
42422
|
-
...params.months ? { months: params.months } : {},
|
|
42423
|
-
...params.owner ? { owner: params.owner } : {}
|
|
42424
|
-
});
|
|
42477
|
+
data = await client.get("/api/analytics", params.months ? { months: params.months } : undefined);
|
|
42425
42478
|
break;
|
|
42426
42479
|
case "category_analytics":
|
|
42427
42480
|
data = await client.get(`/api/analytics/categories/${params.slug}`, params.months ? { months: params.months } : undefined);
|
|
@@ -42606,29 +42659,40 @@ function registerExportTools(server, client, crypto3) {
|
|
|
42606
42659
|
// src/tools/goals.ts
|
|
42607
42660
|
function registerGoalTools(server, client) {
|
|
42608
42661
|
server.registerTool("list_savings", {
|
|
42609
|
-
description: "List savings vehicles. type='goals' → savings goals with progress;
|
|
42662
|
+
description: "List savings vehicles. type='goals' → savings goals with progress; 'emis' → EMIs (loans repaid monthly); 'sinking_funds' → funds for a future expense; 'insurance' → insurance policies; 'contributions' → contribution history of one goal (pass id).",
|
|
42610
42663
|
inputSchema: {
|
|
42611
|
-
type: exports_external.enum(["goals", "emis", "sinking_funds"])
|
|
42664
|
+
type: exports_external.enum(["goals", "emis", "sinking_funds", "insurance", "contributions"]),
|
|
42665
|
+
id: exports_external.string().optional().describe("Goal UUID — required for type='contributions'")
|
|
42612
42666
|
}
|
|
42613
|
-
}, async ({ type }) => {
|
|
42614
|
-
|
|
42615
|
-
|
|
42616
|
-
|
|
42667
|
+
}, async ({ type, id }) => {
|
|
42668
|
+
if (type === "contributions") {
|
|
42669
|
+
if (!id)
|
|
42670
|
+
throw new Error("list_savings type='contributions' requires id (goal UUID)");
|
|
42671
|
+
const data = await client.get(`/api/goals/${id}/contributions`);
|
|
42672
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42673
|
+
}
|
|
42674
|
+
const all2 = await client.get("/api/goals");
|
|
42675
|
+
const key = type === "sinking_funds" ? "sinkingFunds" : type;
|
|
42676
|
+
return {
|
|
42677
|
+
content: [
|
|
42678
|
+
{ type: "text", text: JSON.stringify({ success: true, data: all2[key] ?? [] }) }
|
|
42679
|
+
]
|
|
42680
|
+
};
|
|
42617
42681
|
});
|
|
42618
42682
|
server.registerTool("upsert_savings", {
|
|
42619
42683
|
description: `Create (or update) a savings vehicle. Field requirements depend on type:
|
|
42620
42684
|
` + `- type='goal': create/update a savings goal. Omit id to create (name + targetAmount required); pass id to update an existing goal (all other fields optional).
|
|
42621
|
-
` + `- type='emi': add an EMI/loan repayment. Requires name, totalAmount, emiAmount, startDate, endDate, totalMonths, owner (interestRate, paidMonths optional).
|
|
42622
|
-
` + "- type='sinking_fund': create a sinking fund for a future expense. Requires name, targetAmount, targetDate
|
|
42685
|
+
` + `- type='emi': add an EMI/loan repayment (pass id to update; then all fields optional). Requires name, totalAmount, emiAmount, startDate, endDate, totalMonths, owner (interestRate, paidMonths optional).
|
|
42686
|
+
` + "- type='sinking_fund': create a sinking fund for a future expense (pass id to update; then all fields optional). Requires name, targetAmount, targetDate, monthlyContribution (currentAmount optional).",
|
|
42623
42687
|
inputSchema: {
|
|
42624
42688
|
type: exports_external.enum(["goal", "emi", "sinking_fund"]),
|
|
42625
|
-
id: exports_external.string().optional().describe("[goal]
|
|
42689
|
+
id: exports_external.string().optional().describe("[goal, emi, sinking_fund] UUID — omit to create, pass to update"),
|
|
42626
42690
|
goalType: exports_external.enum(["emergency_fund", "travel", "car", "house", "education", "retirement", "custom"]).optional().describe("[goal] Goal category — required when creating"),
|
|
42627
42691
|
name: exports_external.string().optional().describe("[goal, emi, sinking_fund] Name"),
|
|
42628
42692
|
targetAmount: exports_external.string().optional().describe("[goal, sinking_fund] Target amount"),
|
|
42629
|
-
targetDate: exports_external.string().optional().describe("[goal, sinking_fund] YYYY-MM-DD"),
|
|
42693
|
+
targetDate: exports_external.string().optional().describe("[goal, sinking_fund] YYYY-MM-DD (fund: due date)"),
|
|
42630
42694
|
currentAmount: exports_external.string().optional().describe("[goal, sinking_fund] Already saved"),
|
|
42631
|
-
monthlyContribution: exports_external.string().optional().describe("[goal] Monthly contribution"),
|
|
42695
|
+
monthlyContribution: exports_external.string().optional().describe("[goal, sinking_fund] Monthly contribution / set-aside"),
|
|
42632
42696
|
owner: exports_external.string().optional().describe("[goal, emi] Owner"),
|
|
42633
42697
|
icon: exports_external.string().optional().describe("[goal] Icon"),
|
|
42634
42698
|
color: exports_external.string().optional().describe("[goal] Color"),
|
|
@@ -42650,7 +42714,7 @@ function registerGoalTools(server, client) {
|
|
|
42650
42714
|
};
|
|
42651
42715
|
if (type === "goal") {
|
|
42652
42716
|
const {
|
|
42653
|
-
id,
|
|
42717
|
+
id: id2,
|
|
42654
42718
|
goalType,
|
|
42655
42719
|
name: name2,
|
|
42656
42720
|
targetAmount: targetAmount2,
|
|
@@ -42674,50 +42738,49 @@ function registerGoalTools(server, client) {
|
|
|
42674
42738
|
color,
|
|
42675
42739
|
notes
|
|
42676
42740
|
};
|
|
42677
|
-
if (!
|
|
42741
|
+
if (!id2)
|
|
42678
42742
|
require2(["name", "targetAmount", "goalType"]);
|
|
42679
|
-
const data2 =
|
|
42743
|
+
const data2 = id2 ? await client.patch(`/api/goals/${id2}`, body2) : await client.post("/api/goals", body2);
|
|
42680
42744
|
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
42681
42745
|
}
|
|
42682
42746
|
if (type === "emi") {
|
|
42683
|
-
|
|
42684
|
-
|
|
42685
|
-
|
|
42686
|
-
|
|
42687
|
-
|
|
42688
|
-
|
|
42689
|
-
|
|
42690
|
-
|
|
42691
|
-
|
|
42692
|
-
|
|
42693
|
-
|
|
42694
|
-
|
|
42695
|
-
|
|
42696
|
-
interestRate,
|
|
42697
|
-
startDate,
|
|
42698
|
-
endDate,
|
|
42699
|
-
totalMonths,
|
|
42700
|
-
paidMonths,
|
|
42701
|
-
owner
|
|
42702
|
-
} = fields;
|
|
42747
|
+
const { id: id2, name: name2, totalAmount, emiAmount, startDate, endDate, totalMonths, paidMonths } = fields;
|
|
42748
|
+
const { owner } = fields;
|
|
42749
|
+
if (!id2) {
|
|
42750
|
+
require2([
|
|
42751
|
+
"name",
|
|
42752
|
+
"totalAmount",
|
|
42753
|
+
"emiAmount",
|
|
42754
|
+
"startDate",
|
|
42755
|
+
"endDate",
|
|
42756
|
+
"totalMonths",
|
|
42757
|
+
"owner"
|
|
42758
|
+
]);
|
|
42759
|
+
}
|
|
42703
42760
|
const body2 = {
|
|
42704
42761
|
name: name2,
|
|
42705
42762
|
totalAmount,
|
|
42706
42763
|
emiAmount,
|
|
42707
|
-
interestRate,
|
|
42708
42764
|
startDate,
|
|
42709
42765
|
endDate,
|
|
42710
42766
|
totalMonths,
|
|
42711
42767
|
paidMonths,
|
|
42712
42768
|
owner
|
|
42713
42769
|
};
|
|
42714
|
-
const data2 = await client.post("/api/goals/emis", body2);
|
|
42770
|
+
const data2 = id2 ? await client.patch(`/api/goals/emis/${id2}`, body2) : await client.post("/api/goals/emis", body2);
|
|
42715
42771
|
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
42716
42772
|
}
|
|
42717
|
-
|
|
42718
|
-
|
|
42719
|
-
|
|
42720
|
-
const
|
|
42773
|
+
const { id, name, targetAmount, targetDate, monthlyContribution, currentAmount } = fields;
|
|
42774
|
+
if (!id)
|
|
42775
|
+
require2(["name", "targetAmount", "targetDate", "monthlyContribution"]);
|
|
42776
|
+
const body = {
|
|
42777
|
+
name,
|
|
42778
|
+
targetAmount,
|
|
42779
|
+
dueDate: targetDate,
|
|
42780
|
+
monthlySetAside: monthlyContribution,
|
|
42781
|
+
currentAmount
|
|
42782
|
+
};
|
|
42783
|
+
const data = id ? await client.patch(`/api/goals/sinking-funds/${id}`, body) : await client.post("/api/goals/sinking-funds", body);
|
|
42721
42784
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42722
42785
|
});
|
|
42723
42786
|
server.registerTool("upsert_insurance", {
|
|
@@ -42754,6 +42817,16 @@ function registerGoalTools(server, client) {
|
|
|
42754
42817
|
const data = await client.post(`/api/goals/${id}/contribute`, body);
|
|
42755
42818
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42756
42819
|
});
|
|
42820
|
+
server.registerTool("delete_savings", {
|
|
42821
|
+
description: "Delete a savings goal, EMI, insurance policy or sinking fund by id. PERMANENT — confirm with the user first.",
|
|
42822
|
+
inputSchema: {
|
|
42823
|
+
type: exports_external.enum(["goal", "emi", "insurance", "sinking_fund"]),
|
|
42824
|
+
id: exports_external.string().describe("UUID of the item to delete")
|
|
42825
|
+
}
|
|
42826
|
+
}, async ({ type, id }) => {
|
|
42827
|
+
const data = type === "goal" ? await client.delete(`/api/goals/${id}`) : type === "emi" ? await client.delete(`/api/goals/emis/${id}`) : type === "insurance" ? await client.delete(`/api/goals/insurance/${id}`) : await client.delete(`/api/goals/sinking-funds/${id}`);
|
|
42828
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42829
|
+
});
|
|
42757
42830
|
}
|
|
42758
42831
|
|
|
42759
42832
|
// src/tools/heatmap.ts
|
|
@@ -42784,6 +42857,9 @@ async function loadBag(client) {
|
|
|
42784
42857
|
const res = await client.get("/api/household-learning");
|
|
42785
42858
|
if (!res.data)
|
|
42786
42859
|
return { bag: structuredClone(EMPTY_BAG), version: 0 };
|
|
42860
|
+
if (res.data.readable === false) {
|
|
42861
|
+
throw new Error("Household learning bag is unreadable (decrypt/parse failed); refusing to modify it. Check ENCRYPTION_KEY / the stored blob before retrying.");
|
|
42862
|
+
}
|
|
42787
42863
|
return { bag: res.data.bag, version: res.data.version };
|
|
42788
42864
|
}
|
|
42789
42865
|
async function saveBag(client, bag, expectedVersion) {
|
|
@@ -42886,12 +42962,24 @@ function registerHouseholdTools(server, client) {
|
|
|
42886
42962
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42887
42963
|
});
|
|
42888
42964
|
server.registerTool("create_invite", {
|
|
42889
|
-
description: "
|
|
42890
|
-
inputSchema: {
|
|
42891
|
-
|
|
42892
|
-
}
|
|
42893
|
-
|
|
42894
|
-
|
|
42965
|
+
description: "Invite someone (by email) to the household. Returns the invite URL; the invite expires in 72h.",
|
|
42966
|
+
inputSchema: { email: exports_external.string().email().describe("Email address of the invitee") }
|
|
42967
|
+
}, async ({ email: email2 }) => {
|
|
42968
|
+
const data = await client.post("/api/households/invites", { email: email2 });
|
|
42969
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42970
|
+
});
|
|
42971
|
+
server.registerTool("list_invites", {
|
|
42972
|
+
description: "List pending household invites (use to find an invite id to revoke).",
|
|
42973
|
+
inputSchema: {}
|
|
42974
|
+
}, async () => {
|
|
42975
|
+
const data = await client.get("/api/households/invites");
|
|
42976
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42977
|
+
});
|
|
42978
|
+
server.registerTool("revoke_invite", {
|
|
42979
|
+
description: "Revoke a pending household invite so its link stops working.",
|
|
42980
|
+
inputSchema: { id: exports_external.string().describe("Invite UUID from list_invites") }
|
|
42981
|
+
}, async ({ id }) => {
|
|
42982
|
+
const data = await client.delete(`/api/households/invites/${id}`);
|
|
42895
42983
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42896
42984
|
});
|
|
42897
42985
|
}
|
|
@@ -42899,20 +42987,26 @@ function registerHouseholdTools(server, client) {
|
|
|
42899
42987
|
// src/tools/investments.ts
|
|
42900
42988
|
function registerInvestmentTools(server, client) {
|
|
42901
42989
|
server.registerTool("list_investments", {
|
|
42902
|
-
description: "List investments by type. holdings = mutual funds, stocks, etc. with current values; sips = Systematic Investment Plans; fixed_deposits = fixed deposits.",
|
|
42990
|
+
description: "List investments by type. holdings = mutual funds, stocks, etc. with current values; sips = Systematic Investment Plans; fixed_deposits = fixed deposits; transactions = buy/sell/dividend history of one holding (pass id).",
|
|
42903
42991
|
inputSchema: {
|
|
42904
|
-
type: exports_external.enum(["holdings", "sips", "fixed_deposits"]).describe("Which investments to list")
|
|
42992
|
+
type: exports_external.enum(["holdings", "sips", "fixed_deposits", "transactions"]).describe("Which investments to list"),
|
|
42993
|
+
id: exports_external.string().optional().describe("Investment UUID — required for type='transactions'")
|
|
42905
42994
|
}
|
|
42906
|
-
}, async ({ type }) => {
|
|
42907
|
-
|
|
42908
|
-
|
|
42995
|
+
}, async ({ type, id }) => {
|
|
42996
|
+
if (type === "transactions") {
|
|
42997
|
+
if (!id)
|
|
42998
|
+
throw new Error("list_investments type='transactions' requires id");
|
|
42999
|
+
const data2 = await client.get(`/api/investments/${id}/transactions`);
|
|
43000
|
+
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
43001
|
+
}
|
|
43002
|
+
const data = type === "sips" ? await client.get("/api/investments/sips") : type === "fixed_deposits" ? await client.get("/api/investments/fds") : await client.get("/api/investments");
|
|
42909
43003
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42910
43004
|
});
|
|
42911
43005
|
server.registerTool("upsert_investment", {
|
|
42912
|
-
description: "Create or update an investment. " + "type=sip: omit id to add a new SIP (name, amount, startDate, sipDay, owner required); pass id to update an existing SIP, including pausing/stopping it via status. " + "type=fixed_deposit: create
|
|
43006
|
+
description: "Create or update an investment. " + "type=sip: omit id to add a new SIP (name, amount, startDate, sipDay, owner required); pass id to update an existing SIP, including pausing/stopping it via status. " + "type=fixed_deposit: omit id to create (bankName, amount, interestRate, startDate, maturityDate, owner required); pass id to update an FD (fields optional). " + "type=holding: create/update a manual stock/MF holding not pulled by a sync, e.g. a Groww position (name, holdingType, owner, investedAmount, currentValue required; re-upserting the same name+platform updates it).",
|
|
42913
43007
|
inputSchema: {
|
|
42914
43008
|
type: exports_external.enum(["sip", "fixed_deposit", "holding"]).describe("Investment type"),
|
|
42915
|
-
id: exports_external.string().optional().describe("sip:
|
|
43009
|
+
id: exports_external.string().optional().describe("sip & fixed_deposit: UUID — omit to add, pass to update"),
|
|
42916
43010
|
name: exports_external.string().optional().describe('sip: fund name (e.g. "Parag Parikh Flexi Cap")'),
|
|
42917
43011
|
startDate: exports_external.string().optional().describe("sip & fixed_deposit: YYYY-MM-DD"),
|
|
42918
43012
|
sipDay: exports_external.number().optional().describe("sip: day of month the SIP debits"),
|
|
@@ -42925,7 +43019,7 @@ function registerInvestmentTools(server, client) {
|
|
|
42925
43019
|
maturityDate: exports_external.string().optional().describe("fixed_deposit: YYYY-MM-DD"),
|
|
42926
43020
|
notes: exports_external.string().optional().describe("fixed_deposit: notes"),
|
|
42927
43021
|
holdingType: exports_external.enum(["mutual_fund", "stock", "gold", "ppf", "nps", "other"]).optional().describe("holding: asset type"),
|
|
42928
|
-
platform: exports_external.string().optional().describe("holding: broker/platform (e.g. groww); defaults to 'manual'"),
|
|
43022
|
+
platform: exports_external.string().optional().describe("sip & holding: broker/platform (e.g. groww); defaults to 'manual'"),
|
|
42929
43023
|
symbol: exports_external.string().optional().describe("holding: ticker symbol (optional)"),
|
|
42930
43024
|
isin: exports_external.string().optional().describe("holding: ISIN (optional)"),
|
|
42931
43025
|
units: exports_external.string().optional().describe("holding: units held (optional)"),
|
|
@@ -42938,15 +43032,15 @@ function registerInvestmentTools(server, client) {
|
|
|
42938
43032
|
if (type === "holding") {
|
|
42939
43033
|
const { name, holdingType, owner: owner2, platform, symbol, isin, units, investedAmount } = fields;
|
|
42940
43034
|
const { currentValue } = fields;
|
|
42941
|
-
const
|
|
43035
|
+
const missing = [
|
|
42942
43036
|
["name", name],
|
|
42943
43037
|
["holdingType", holdingType],
|
|
42944
43038
|
["owner", owner2],
|
|
42945
43039
|
["investedAmount", investedAmount],
|
|
42946
43040
|
["currentValue", currentValue]
|
|
42947
43041
|
].filter(([, v]) => v === undefined).map(([k]) => k);
|
|
42948
|
-
if (
|
|
42949
|
-
throw new Error(`Missing required fields for holding: ${
|
|
43042
|
+
if (missing.length > 0) {
|
|
43043
|
+
throw new Error(`Missing required fields for holding: ${missing.join(", ")}`);
|
|
42950
43044
|
}
|
|
42951
43045
|
const data2 = await client.post("/api/investments/holdings", {
|
|
42952
43046
|
name,
|
|
@@ -42963,7 +43057,7 @@ function registerInvestmentTools(server, client) {
|
|
|
42963
43057
|
}
|
|
42964
43058
|
if (type === "sip") {
|
|
42965
43059
|
const {
|
|
42966
|
-
id,
|
|
43060
|
+
id: id2,
|
|
42967
43061
|
name,
|
|
42968
43062
|
amount: amount2,
|
|
42969
43063
|
startDate: startDate2,
|
|
@@ -42972,55 +43066,77 @@ function registerInvestmentTools(server, client) {
|
|
|
42972
43066
|
folioNumber,
|
|
42973
43067
|
category,
|
|
42974
43068
|
status,
|
|
42975
|
-
endDate
|
|
43069
|
+
endDate,
|
|
43070
|
+
platform
|
|
42976
43071
|
} = fields;
|
|
42977
|
-
if (!
|
|
42978
|
-
const
|
|
43072
|
+
if (!id2) {
|
|
43073
|
+
const missing = [
|
|
42979
43074
|
["name", name],
|
|
42980
43075
|
["amount", amount2],
|
|
42981
43076
|
["startDate", startDate2],
|
|
42982
43077
|
["sipDay", sipDay],
|
|
42983
43078
|
["owner", owner2]
|
|
42984
43079
|
].filter(([, v]) => v === undefined).map(([k]) => k);
|
|
42985
|
-
if (
|
|
42986
|
-
throw new Error(`Missing required fields for new SIP: ${
|
|
43080
|
+
if (missing.length > 0) {
|
|
43081
|
+
throw new Error(`Missing required fields for new SIP: ${missing.join(", ")}`);
|
|
42987
43082
|
}
|
|
42988
43083
|
}
|
|
42989
43084
|
const sipFields = {
|
|
42990
43085
|
name,
|
|
42991
43086
|
amount: amount2,
|
|
42992
43087
|
startDate: startDate2,
|
|
42993
|
-
sipDay,
|
|
43088
|
+
sipDate: sipDay,
|
|
42994
43089
|
owner: owner2,
|
|
42995
43090
|
folioNumber,
|
|
42996
43091
|
category,
|
|
42997
43092
|
status,
|
|
42998
|
-
endDate
|
|
43093
|
+
endDate,
|
|
43094
|
+
platform: id2 ? platform : platform ?? "manual"
|
|
42999
43095
|
};
|
|
43000
|
-
const data2 =
|
|
43096
|
+
const data2 = id2 ? await client.patch(`/api/investments/sips/${id2}`, sipFields) : await client.post("/api/investments/sips", sipFields);
|
|
43001
43097
|
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
43002
43098
|
}
|
|
43003
|
-
const { bankName, amount, interestRate, startDate, maturityDate, owner, notes } = fields;
|
|
43004
|
-
|
|
43005
|
-
[
|
|
43006
|
-
|
|
43007
|
-
|
|
43008
|
-
|
|
43009
|
-
|
|
43010
|
-
|
|
43011
|
-
|
|
43012
|
-
|
|
43013
|
-
|
|
43014
|
-
|
|
43015
|
-
|
|
43016
|
-
|
|
43017
|
-
|
|
43099
|
+
const { id, bankName, amount, interestRate, startDate, maturityDate, owner, notes } = fields;
|
|
43100
|
+
if (!id) {
|
|
43101
|
+
const missing = [
|
|
43102
|
+
["bankName", bankName],
|
|
43103
|
+
["amount", amount],
|
|
43104
|
+
["interestRate", interestRate],
|
|
43105
|
+
["startDate", startDate],
|
|
43106
|
+
["maturityDate", maturityDate],
|
|
43107
|
+
["owner", owner]
|
|
43108
|
+
].filter(([, v]) => v === undefined).map(([k]) => k);
|
|
43109
|
+
if (missing.length > 0) {
|
|
43110
|
+
throw new Error(`Missing required fields for fixed deposit: ${missing.join(", ")}`);
|
|
43111
|
+
}
|
|
43112
|
+
}
|
|
43113
|
+
const fdBody = {
|
|
43114
|
+
bank: bankName,
|
|
43115
|
+
principal: amount,
|
|
43018
43116
|
interestRate,
|
|
43019
43117
|
startDate,
|
|
43020
43118
|
maturityDate,
|
|
43021
43119
|
owner,
|
|
43022
43120
|
notes
|
|
43023
|
-
}
|
|
43121
|
+
};
|
|
43122
|
+
const data = id ? await client.patch(`/api/investments/fds/${id}`, fdBody) : await client.post("/api/investments/fds", fdBody);
|
|
43123
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43124
|
+
});
|
|
43125
|
+
server.registerTool("delete_investment", {
|
|
43126
|
+
description: "Delete a SIP or fixed deposit by id. PERMANENT (FDs are soft-deleted) — confirm with the user first.",
|
|
43127
|
+
inputSchema: {
|
|
43128
|
+
type: exports_external.enum(["sip", "fixed_deposit"]),
|
|
43129
|
+
id: exports_external.string().describe("SIP or FD UUID")
|
|
43130
|
+
}
|
|
43131
|
+
}, async ({ type, id }) => {
|
|
43132
|
+
const data = type === "sip" ? await client.delete(`/api/investments/sips/${id}`) : await client.delete(`/api/investments/fds/${id}`);
|
|
43133
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43134
|
+
});
|
|
43135
|
+
server.registerTool("sync_zerodha", {
|
|
43136
|
+
description: "Zerodha broker link. action='token_health' → check whether the access token is still valid (expired tokens need re-login in the web app); action='sync' → pull latest stocks, mutual funds and SIPs.",
|
|
43137
|
+
inputSchema: { action: exports_external.enum(["token_health", "sync"]) }
|
|
43138
|
+
}, async ({ action }) => {
|
|
43139
|
+
const data = action === "sync" ? await client.post("/api/investments/zerodha/sync") : await client.get("/api/investments/zerodha/token-health");
|
|
43024
43140
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43025
43141
|
});
|
|
43026
43142
|
}
|
|
@@ -43789,6 +43905,13 @@ function resolveAlias(name) {
|
|
|
43789
43905
|
}
|
|
43790
43906
|
return null;
|
|
43791
43907
|
}
|
|
43908
|
+
function aliasesMatch(a, b) {
|
|
43909
|
+
const resolvedA = resolveAlias(a);
|
|
43910
|
+
const resolvedB = resolveAlias(b);
|
|
43911
|
+
if (resolvedA === null || resolvedB === null)
|
|
43912
|
+
return false;
|
|
43913
|
+
return resolvedA === resolvedB;
|
|
43914
|
+
}
|
|
43792
43915
|
// ../../node_modules/.bun/fuzzball@2.2.3/node_modules/fuzzball/dist/esm/fuzzball.esm.min.js
|
|
43793
43916
|
var e;
|
|
43794
43917
|
var t = typeof globalThis != "undefined" ? globalThis : typeof window != "undefined" ? window : typeof global != "undefined" ? global : typeof self != "undefined" ? self : {};
|
|
@@ -45505,9 +45628,35 @@ var ta = Ro.dedupe;
|
|
|
45505
45628
|
|
|
45506
45629
|
// ../reconciliation/src/normalise.ts
|
|
45507
45630
|
var CORPORATE_SUFFIXES = /\b(PRIVATE|PVT\.?|LIMITED|LTD\.?)\s*\w*/gi;
|
|
45631
|
+
var TOKEN_SET_THRESHOLD = 80;
|
|
45508
45632
|
function normaliseName(name) {
|
|
45509
45633
|
return name.replace(CORPORATE_SUFFIXES, "").replace(/[^\w\s]/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
|
45510
45634
|
}
|
|
45635
|
+
function getMatchName(extractedName, description) {
|
|
45636
|
+
if (extractedName && extractedName.trim().length > 0) {
|
|
45637
|
+
return normaliseName(extractedName);
|
|
45638
|
+
}
|
|
45639
|
+
return normaliseName(description);
|
|
45640
|
+
}
|
|
45641
|
+
function isSameMerchant(a2, b2) {
|
|
45642
|
+
const normA = normaliseName(a2);
|
|
45643
|
+
const normB = normaliseName(b2);
|
|
45644
|
+
if (normA.length === 0 || normB.length === 0)
|
|
45645
|
+
return false;
|
|
45646
|
+
if (normA === normB)
|
|
45647
|
+
return true;
|
|
45648
|
+
if (aliasesMatch(normA, normB))
|
|
45649
|
+
return true;
|
|
45650
|
+
const shorter = Math.min(normA.length, normB.length);
|
|
45651
|
+
const longer = Math.max(normA.length, normB.length);
|
|
45652
|
+
if (shorter < 4 || shorter / longer < 0.4)
|
|
45653
|
+
return false;
|
|
45654
|
+
if (Fo(normA, normB) >= TOKEN_SET_THRESHOLD)
|
|
45655
|
+
return true;
|
|
45656
|
+
if (Bo(normA, normB) >= 90)
|
|
45657
|
+
return true;
|
|
45658
|
+
return false;
|
|
45659
|
+
}
|
|
45511
45660
|
|
|
45512
45661
|
// ../reconciliation/src/entity.ts
|
|
45513
45662
|
var FUZZBALL_THRESHOLD = 92;
|
|
@@ -45649,6 +45798,151 @@ function entityRule(e2, bag) {
|
|
|
45649
45798
|
return bag.merchantRules[e2.merchantId] ?? null;
|
|
45650
45799
|
return bag.personRules[e2.personId] ?? null;
|
|
45651
45800
|
}
|
|
45801
|
+
// ../reconciliation/src/match.ts
|
|
45802
|
+
var DEFAULT_DATE_TOLERANCE = 2;
|
|
45803
|
+
var DEFAULT_AMOUNT_TOLERANCE = 0.02;
|
|
45804
|
+
function toDays(dateStr) {
|
|
45805
|
+
const [y2, m2, d2] = dateStr.split("-").map(Number);
|
|
45806
|
+
return Math.round(Date.UTC(y2, m2 - 1, d2) / 86400000);
|
|
45807
|
+
}
|
|
45808
|
+
function amountsClose(a2, b2, tolerance) {
|
|
45809
|
+
if (a2 === 0 || b2 === 0)
|
|
45810
|
+
return false;
|
|
45811
|
+
return Math.abs(a2 - b2) / Math.max(a2, b2) <= tolerance;
|
|
45812
|
+
}
|
|
45813
|
+
function namesSimilar(inc, ex) {
|
|
45814
|
+
if (inc.upiId && ex.upiId && inc.upiId === ex.upiId)
|
|
45815
|
+
return true;
|
|
45816
|
+
const incName = getMatchName(inc.extractedName, inc.description);
|
|
45817
|
+
const exName = getMatchName(ex.extractedName ?? ex.merchantName, ex.description);
|
|
45818
|
+
return isSameMerchant(incName, exName);
|
|
45819
|
+
}
|
|
45820
|
+
function matchTransactions(existing, incoming, config2) {
|
|
45821
|
+
const dateTolerance = config2?.dateTolerance ?? DEFAULT_DATE_TOLERANCE;
|
|
45822
|
+
const amountTolerance = config2?.amountTolerance ?? DEFAULT_AMOUNT_TOLERANCE;
|
|
45823
|
+
const claimedExisting = new Set;
|
|
45824
|
+
const claimedIncoming = new Set;
|
|
45825
|
+
const matched = [];
|
|
45826
|
+
const conflicts = [];
|
|
45827
|
+
const eligible = existing.filter((t2) => !t2.isDuplicate && !t2.isIgnored);
|
|
45828
|
+
for (let i2 = 0;i2 < incoming.length; i2++) {
|
|
45829
|
+
if (claimedIncoming.has(i2))
|
|
45830
|
+
continue;
|
|
45831
|
+
const inc = incoming[i2];
|
|
45832
|
+
for (const ex of eligible) {
|
|
45833
|
+
if (claimedExisting.has(ex.id))
|
|
45834
|
+
continue;
|
|
45835
|
+
if (inc.description === ex.description && inc.amount === ex.amount && inc.type === ex.type && inc.date === ex.date) {
|
|
45836
|
+
matched.push({
|
|
45837
|
+
existing: ex,
|
|
45838
|
+
incoming: inc,
|
|
45839
|
+
incomingIndex: i2,
|
|
45840
|
+
confidence: "exact",
|
|
45841
|
+
reason: "Same row: identical description, amount, type, and date"
|
|
45842
|
+
});
|
|
45843
|
+
claimedExisting.add(ex.id);
|
|
45844
|
+
claimedIncoming.add(i2);
|
|
45845
|
+
break;
|
|
45846
|
+
}
|
|
45847
|
+
}
|
|
45848
|
+
}
|
|
45849
|
+
for (let i2 = 0;i2 < incoming.length; i2++) {
|
|
45850
|
+
if (claimedIncoming.has(i2))
|
|
45851
|
+
continue;
|
|
45852
|
+
const inc = incoming[i2];
|
|
45853
|
+
if (!inc.referenceNumber)
|
|
45854
|
+
continue;
|
|
45855
|
+
for (const ex of eligible) {
|
|
45856
|
+
if (claimedExisting.has(ex.id))
|
|
45857
|
+
continue;
|
|
45858
|
+
if (ex.referenceNumber === inc.referenceNumber && inc.type === ex.type && inc.amount === ex.amount) {
|
|
45859
|
+
matched.push({
|
|
45860
|
+
existing: ex,
|
|
45861
|
+
incoming: inc,
|
|
45862
|
+
incomingIndex: i2,
|
|
45863
|
+
confidence: "exact",
|
|
45864
|
+
reason: `Reference number match: ${inc.referenceNumber}`
|
|
45865
|
+
});
|
|
45866
|
+
claimedExisting.add(ex.id);
|
|
45867
|
+
claimedIncoming.add(i2);
|
|
45868
|
+
break;
|
|
45869
|
+
}
|
|
45870
|
+
}
|
|
45871
|
+
}
|
|
45872
|
+
for (let i2 = 0;i2 < incoming.length; i2++) {
|
|
45873
|
+
if (claimedIncoming.has(i2))
|
|
45874
|
+
continue;
|
|
45875
|
+
const inc = incoming[i2];
|
|
45876
|
+
for (const ex of eligible) {
|
|
45877
|
+
if (claimedExisting.has(ex.id))
|
|
45878
|
+
continue;
|
|
45879
|
+
if (inc.amount === ex.amount && inc.type === ex.type && inc.date === ex.date && namesSimilar(inc, ex)) {
|
|
45880
|
+
matched.push({
|
|
45881
|
+
existing: ex,
|
|
45882
|
+
incoming: inc,
|
|
45883
|
+
incomingIndex: i2,
|
|
45884
|
+
confidence: "exact",
|
|
45885
|
+
reason: `Exact match: ${inc.amount} on ${inc.date}`
|
|
45886
|
+
});
|
|
45887
|
+
claimedExisting.add(ex.id);
|
|
45888
|
+
claimedIncoming.add(i2);
|
|
45889
|
+
break;
|
|
45890
|
+
}
|
|
45891
|
+
}
|
|
45892
|
+
}
|
|
45893
|
+
for (let i2 = 0;i2 < incoming.length; i2++) {
|
|
45894
|
+
if (claimedIncoming.has(i2))
|
|
45895
|
+
continue;
|
|
45896
|
+
const inc = incoming[i2];
|
|
45897
|
+
const incDays = toDays(inc.date);
|
|
45898
|
+
for (const ex of eligible) {
|
|
45899
|
+
if (claimedExisting.has(ex.id))
|
|
45900
|
+
continue;
|
|
45901
|
+
const daysDiff = Math.abs(incDays - toDays(ex.date));
|
|
45902
|
+
if (daysDiff > 0 && daysDiff <= dateTolerance && inc.amount === ex.amount && inc.type === ex.type && namesSimilar(inc, ex)) {
|
|
45903
|
+
matched.push({
|
|
45904
|
+
existing: ex,
|
|
45905
|
+
incoming: inc,
|
|
45906
|
+
incomingIndex: i2,
|
|
45907
|
+
confidence: "date_tolerance",
|
|
45908
|
+
reason: `Amount + name match, ${daysDiff} day${daysDiff > 1 ? "s" : ""} apart`
|
|
45909
|
+
});
|
|
45910
|
+
claimedExisting.add(ex.id);
|
|
45911
|
+
claimedIncoming.add(i2);
|
|
45912
|
+
break;
|
|
45913
|
+
}
|
|
45914
|
+
}
|
|
45915
|
+
}
|
|
45916
|
+
for (let i2 = 0;i2 < incoming.length; i2++) {
|
|
45917
|
+
if (claimedIncoming.has(i2))
|
|
45918
|
+
continue;
|
|
45919
|
+
const inc = incoming[i2];
|
|
45920
|
+
const incDays = toDays(inc.date);
|
|
45921
|
+
for (const ex of eligible) {
|
|
45922
|
+
if (claimedExisting.has(ex.id))
|
|
45923
|
+
continue;
|
|
45924
|
+
const daysDiff = Math.abs(incDays - toDays(ex.date));
|
|
45925
|
+
if (daysDiff <= dateTolerance && inc.type === ex.type && inc.amount !== ex.amount && amountsClose(inc.amount, ex.amount, amountTolerance) && namesSimilar(inc, ex)) {
|
|
45926
|
+
const pctDiff = (Math.abs(inc.amount - ex.amount) / Math.max(inc.amount, ex.amount) * 100).toFixed(1);
|
|
45927
|
+
conflicts.push({
|
|
45928
|
+
existing: ex,
|
|
45929
|
+
incoming: inc,
|
|
45930
|
+
incomingIndex: i2,
|
|
45931
|
+
reason: `Amount differs by ${pctDiff}% (${ex.amount} vs ${inc.amount})`
|
|
45932
|
+
});
|
|
45933
|
+
claimedExisting.add(ex.id);
|
|
45934
|
+
claimedIncoming.add(i2);
|
|
45935
|
+
break;
|
|
45936
|
+
}
|
|
45937
|
+
}
|
|
45938
|
+
}
|
|
45939
|
+
const unmatchedIndices = [];
|
|
45940
|
+
for (let i2 = 0;i2 < incoming.length; i2++) {
|
|
45941
|
+
if (!claimedIncoming.has(i2))
|
|
45942
|
+
unmatchedIndices.push(i2);
|
|
45943
|
+
}
|
|
45944
|
+
return { matched, conflicts, unmatchedIndices };
|
|
45945
|
+
}
|
|
45652
45946
|
// ../reconciliation/src/settlement.ts
|
|
45653
45947
|
var SETTLEMENT_PATTERNS = [
|
|
45654
45948
|
/\bCRED\b.*\bPAYMENT\b/i,
|
|
@@ -45725,6 +46019,61 @@ function settlementEnrichedData(description, type, source) {
|
|
|
45725
46019
|
out.settlementCardLast4 = last4;
|
|
45726
46020
|
return out;
|
|
45727
46021
|
}
|
|
46022
|
+
function detectSettlements(transactions, accountLinks) {
|
|
46023
|
+
const settlements = [];
|
|
46024
|
+
const debits = transactions.filter((t2) => t2.type === "debit" && !t2.isDuplicate && !t2.isIgnored && !t2.isTransfer);
|
|
46025
|
+
for (const debit of debits) {
|
|
46026
|
+
const patternMatch = isSettlementDescription(debit.description);
|
|
46027
|
+
if (!patternMatch)
|
|
46028
|
+
continue;
|
|
46029
|
+
const linkedAccount = findLinkedCreditCard(debit, accountLinks);
|
|
46030
|
+
const { brand, last4 } = extractSettlementCardInfo(debit.description);
|
|
46031
|
+
settlements.push({
|
|
46032
|
+
bankDebit: debit,
|
|
46033
|
+
creditAccountId: linkedAccount?.creditAccountId ?? "unknown",
|
|
46034
|
+
statementTotal: null,
|
|
46035
|
+
reason: linkedAccount ? `Settlement for ${linkedAccount.creditAccountId} (keyword: ${linkedAccount.settlementKeyword})` : `Likely CC settlement (pattern: ${patternMatch})`,
|
|
46036
|
+
cardBrand: brand,
|
|
46037
|
+
cardLast4: last4
|
|
46038
|
+
});
|
|
46039
|
+
}
|
|
46040
|
+
return settlements;
|
|
46041
|
+
}
|
|
46042
|
+
function findLinkedCreditCard(debit, accountLinks) {
|
|
46043
|
+
if (!debit.accountId)
|
|
46044
|
+
return null;
|
|
46045
|
+
for (const link of accountLinks) {
|
|
46046
|
+
if (link.debitAccountId !== debit.accountId)
|
|
46047
|
+
continue;
|
|
46048
|
+
if (link.settlementKeyword) {
|
|
46049
|
+
const normDesc = normaliseName(debit.description);
|
|
46050
|
+
if (normDesc.includes(normaliseName(link.settlementKeyword))) {
|
|
46051
|
+
return link;
|
|
46052
|
+
}
|
|
46053
|
+
} else {
|
|
46054
|
+
return link;
|
|
46055
|
+
}
|
|
46056
|
+
}
|
|
46057
|
+
return null;
|
|
46058
|
+
}
|
|
46059
|
+
|
|
46060
|
+
// ../reconciliation/src/reconcile.ts
|
|
46061
|
+
function reconcile(existing, incoming, config2) {
|
|
46062
|
+
const accountLinks = config2?.accountLinks ?? [];
|
|
46063
|
+
const settlements = detectSettlements(existing, accountLinks);
|
|
46064
|
+
const { matched, conflicts, unmatchedIndices } = matchTransactions(existing, incoming.transactions, {
|
|
46065
|
+
dateTolerance: config2?.dateTolerance,
|
|
46066
|
+
amountTolerance: config2?.amountTolerance
|
|
46067
|
+
});
|
|
46068
|
+
const unmatched = unmatchedIndices.map((i2) => incoming.transactions[i2]);
|
|
46069
|
+
return {
|
|
46070
|
+
matched,
|
|
46071
|
+
settlements,
|
|
46072
|
+
conflicts,
|
|
46073
|
+
unmatched,
|
|
46074
|
+
unmatchedIndices
|
|
46075
|
+
};
|
|
46076
|
+
}
|
|
45728
46077
|
// src/lib/link-batches.ts
|
|
45729
46078
|
var MAX_LINKS = 500;
|
|
45730
46079
|
var MAX_TARGETS = 25;
|
|
@@ -45752,1087 +46101,2070 @@ async function postLinks(client, links) {
|
|
|
45752
46101
|
}
|
|
45753
46102
|
}
|
|
45754
46103
|
|
|
45755
|
-
// src/lib/
|
|
45756
|
-
function
|
|
45757
|
-
|
|
45758
|
-
|
|
45759
|
-
|
|
45760
|
-
|
|
45761
|
-
|
|
45762
|
-
|
|
45763
|
-
|
|
45764
|
-
|
|
45765
|
-
|
|
45766
|
-
isTransfer: !!r2.isTransfer,
|
|
45767
|
-
isIgnored: !!r2.isIgnored,
|
|
45768
|
-
isDuplicate: !!r2.isDuplicate
|
|
45769
|
-
};
|
|
46104
|
+
// src/lib/map-limit.ts
|
|
46105
|
+
async function mapLimit(items, limit, fn2) {
|
|
46106
|
+
const out = new Array(items.length);
|
|
46107
|
+
let next = 0;
|
|
46108
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
46109
|
+
while (next < items.length) {
|
|
46110
|
+
const i2 = next++;
|
|
46111
|
+
out[i2] = await fn2(items[i2]);
|
|
46112
|
+
}
|
|
46113
|
+
}));
|
|
46114
|
+
return out;
|
|
45770
46115
|
}
|
|
45771
|
-
|
|
45772
|
-
|
|
45773
|
-
|
|
45774
|
-
|
|
45775
|
-
|
|
45776
|
-
|
|
45777
|
-
|
|
45778
|
-
|
|
45779
|
-
|
|
45780
|
-
|
|
45781
|
-
|
|
45782
|
-
|
|
45783
|
-
|
|
45784
|
-
|
|
45785
|
-
|
|
46116
|
+
// ../parsers/src/amex/credit-card-pdf.ts
|
|
46117
|
+
var _pdfjs = null;
|
|
46118
|
+
async function getPdfjs() {
|
|
46119
|
+
if (!_pdfjs) {
|
|
46120
|
+
_pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
46121
|
+
_pdfjs.GlobalWorkerOptions.workerSrc = import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs");
|
|
46122
|
+
}
|
|
46123
|
+
return _pdfjs;
|
|
46124
|
+
}
|
|
46125
|
+
var X_DATE_MAX = 20;
|
|
46126
|
+
var X_DESC_MIN = 80;
|
|
46127
|
+
var X_DESC_MAX = 340;
|
|
46128
|
+
var X_AMOUNT_MIN = 465;
|
|
46129
|
+
var Y_DATA_MIN = 260;
|
|
46130
|
+
var ROW_TOLERANCE = 12;
|
|
46131
|
+
async function parseAmexCreditCardPdf(data, password) {
|
|
46132
|
+
const pdfjs = await getPdfjs();
|
|
46133
|
+
const loadOptions = {
|
|
46134
|
+
data: new Uint8Array(data),
|
|
46135
|
+
useWorkerFetch: false,
|
|
46136
|
+
isEvalSupported: false,
|
|
46137
|
+
useSystemFonts: true
|
|
46138
|
+
};
|
|
46139
|
+
if (password)
|
|
46140
|
+
loadOptions.password = password;
|
|
46141
|
+
const pdf = await pdfjs.getDocument(loadOptions).promise;
|
|
46142
|
+
const items = await extractTextItems(pdf);
|
|
46143
|
+
const metadata = extractMetadata(items);
|
|
46144
|
+
const transactions = parseTransactions(items, metadata);
|
|
46145
|
+
return {
|
|
46146
|
+
bank: "amex",
|
|
46147
|
+
accountType: "credit_card",
|
|
46148
|
+
accountNumber: metadata.accountLast4,
|
|
46149
|
+
periodStart: metadata.periodStart,
|
|
46150
|
+
periodEnd: metadata.periodEnd,
|
|
46151
|
+
transactions
|
|
46152
|
+
};
|
|
46153
|
+
}
|
|
46154
|
+
async function extractTextItems(pdf) {
|
|
46155
|
+
const result = [];
|
|
46156
|
+
for (let p2 = 1;p2 <= pdf.numPages; p2++) {
|
|
46157
|
+
const page = await pdf.getPage(p2);
|
|
46158
|
+
const content = await page.getTextContent();
|
|
46159
|
+
for (const item of content.items) {
|
|
46160
|
+
const ti = item;
|
|
46161
|
+
if (typeof ti.str !== "string" || !ti.str.trim())
|
|
46162
|
+
continue;
|
|
46163
|
+
result.push({
|
|
46164
|
+
page: p2,
|
|
46165
|
+
x: Math.round(ti.transform[4]),
|
|
46166
|
+
y: Math.round(ti.transform[5]),
|
|
46167
|
+
str: ti.str
|
|
46168
|
+
});
|
|
46169
|
+
}
|
|
45786
46170
|
}
|
|
45787
|
-
return
|
|
46171
|
+
return result;
|
|
45788
46172
|
}
|
|
45789
|
-
|
|
45790
|
-
|
|
45791
|
-
const
|
|
45792
|
-
const
|
|
45793
|
-
|
|
45794
|
-
|
|
45795
|
-
|
|
45796
|
-
|
|
45797
|
-
let
|
|
45798
|
-
|
|
45799
|
-
|
|
45800
|
-
|
|
45801
|
-
|
|
45802
|
-
|
|
45803
|
-
|
|
45804
|
-
|
|
45805
|
-
|
|
46173
|
+
function extractMetadata(items) {
|
|
46174
|
+
const page1Items = items.filter((i2) => i2.page === 1);
|
|
46175
|
+
const rowStrings = groupIntoRowStrings(page1Items);
|
|
46176
|
+
const fullText = rowStrings.join(" ");
|
|
46177
|
+
let accountLast4 = null;
|
|
46178
|
+
let periodStart = null;
|
|
46179
|
+
let periodEnd = null;
|
|
46180
|
+
let periodEndYear = new Date().getFullYear();
|
|
46181
|
+
let periodEndMonth = 1;
|
|
46182
|
+
const accountMatch = fullText.match(/XXXX-XXXXXX-(\d+)/);
|
|
46183
|
+
if (accountMatch) {
|
|
46184
|
+
accountLast4 = accountMatch[1].slice(-4);
|
|
46185
|
+
}
|
|
46186
|
+
const periodMatch = fullText.match(/From\s+(\w+\s+\d+)\s+to\s+(\w+\s+\d+,?\s*\d{4})/i);
|
|
46187
|
+
if (periodMatch) {
|
|
46188
|
+
const endParsed = parseMonthDayYear(periodMatch[2]);
|
|
46189
|
+
if (endParsed) {
|
|
46190
|
+
periodEnd = endParsed.iso;
|
|
46191
|
+
periodEndYear = endParsed.year;
|
|
46192
|
+
periodEndMonth = endParsed.month;
|
|
46193
|
+
const startParsed = parseMonthDay(periodMatch[1]);
|
|
46194
|
+
if (startParsed) {
|
|
46195
|
+
const startYear = startParsed.month > periodEndMonth ? periodEndYear - 1 : periodEndYear;
|
|
46196
|
+
periodStart = `${startYear}-${String(startParsed.month).padStart(2, "0")}-${String(startParsed.day).padStart(2, "0")}`;
|
|
46197
|
+
}
|
|
46198
|
+
}
|
|
46199
|
+
}
|
|
46200
|
+
return { accountLast4, periodStart, periodEnd, periodEndYear, periodEndMonth };
|
|
46201
|
+
}
|
|
46202
|
+
function groupIntoRowStrings(items) {
|
|
46203
|
+
const buckets = new Map;
|
|
46204
|
+
for (const item of items) {
|
|
46205
|
+
const pagePrefix = `${item.page}:`;
|
|
46206
|
+
let bucketKey = null;
|
|
46207
|
+
for (const key of buckets.keys()) {
|
|
46208
|
+
if (!key.startsWith(pagePrefix))
|
|
46209
|
+
continue;
|
|
46210
|
+
if (Math.abs(item.y - parseInt(key.slice(pagePrefix.length), 10)) <= ROW_TOLERANCE) {
|
|
46211
|
+
bucketKey = key;
|
|
46212
|
+
break;
|
|
46213
|
+
}
|
|
46214
|
+
}
|
|
46215
|
+
if (bucketKey === null) {
|
|
46216
|
+
bucketKey = `${item.page}:${item.y}`;
|
|
46217
|
+
buckets.set(bucketKey, [item]);
|
|
46218
|
+
} else {
|
|
46219
|
+
const existing = buckets.get(bucketKey);
|
|
46220
|
+
if (existing)
|
|
46221
|
+
existing.push(item);
|
|
46222
|
+
}
|
|
45806
46223
|
}
|
|
45807
|
-
|
|
45808
|
-
return {
|
|
45809
|
-
id: r2.id,
|
|
45810
|
-
date: r2.date,
|
|
45811
|
-
amt: Number(r2.amount),
|
|
45812
|
-
t: r2.type === "credit" ? "cr" : "dr",
|
|
45813
|
-
desc: desc.length > MAX_DESC ? `${desc.slice(0, MAX_DESC)}…` : desc,
|
|
45814
|
-
...categorySlug ? { cat: categorySlug } : {},
|
|
45815
|
-
...merchantName ? { merchant: merchantName } : {},
|
|
45816
|
-
...person ? { person } : {},
|
|
45817
|
-
...flags.length ? { flags } : {}
|
|
45818
|
-
};
|
|
46224
|
+
return [...buckets.values()].map((row) => row.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" "));
|
|
45819
46225
|
}
|
|
45820
|
-
|
|
45821
|
-
|
|
45822
|
-
|
|
45823
|
-
|
|
45824
|
-
|
|
45825
|
-
|
|
45826
|
-
|
|
45827
|
-
"rtgs",
|
|
45828
|
-
"ach",
|
|
45829
|
-
"nach",
|
|
45830
|
-
"ecs",
|
|
45831
|
-
"txn",
|
|
45832
|
-
"transaction",
|
|
45833
|
-
"payment",
|
|
45834
|
-
"paid",
|
|
45835
|
-
"pay",
|
|
45836
|
-
"to",
|
|
45837
|
-
"from",
|
|
45838
|
-
"ref",
|
|
45839
|
-
"refno",
|
|
45840
|
-
"debit",
|
|
45841
|
-
"credit",
|
|
45842
|
-
"card",
|
|
45843
|
-
"mob",
|
|
45844
|
-
"mobile",
|
|
45845
|
-
"bank",
|
|
45846
|
-
"transfer",
|
|
45847
|
-
"trf",
|
|
45848
|
-
"by",
|
|
45849
|
-
"for",
|
|
45850
|
-
"the",
|
|
45851
|
-
"via",
|
|
45852
|
-
"ecom",
|
|
45853
|
-
"purchase",
|
|
45854
|
-
"online",
|
|
45855
|
-
"inr",
|
|
45856
|
-
"rs",
|
|
45857
|
-
"towards",
|
|
45858
|
-
"ibl",
|
|
45859
|
-
"ybl",
|
|
45860
|
-
"oksbi",
|
|
45861
|
-
"okhdfcbank",
|
|
45862
|
-
"okicici",
|
|
45863
|
-
"okaxis",
|
|
45864
|
-
"axl",
|
|
45865
|
-
"apl",
|
|
45866
|
-
"paytm",
|
|
45867
|
-
"icici",
|
|
45868
|
-
"hdfc",
|
|
45869
|
-
"sbi",
|
|
45870
|
-
"axis",
|
|
45871
|
-
"utib",
|
|
45872
|
-
"sbin",
|
|
45873
|
-
"ifsc",
|
|
45874
|
-
"vpa",
|
|
45875
|
-
"bill",
|
|
45876
|
-
"cas",
|
|
45877
|
-
"dr",
|
|
45878
|
-
"cr"
|
|
45879
|
-
]);
|
|
45880
|
-
var GOOD_TOKEN = /^[a-z][a-z&']{2,}$/;
|
|
45881
|
-
var MAX_RUN = 3;
|
|
45882
|
-
function descriptionPattern(description) {
|
|
45883
|
-
const tokens = normaliseName(description).split(" ").filter(Boolean);
|
|
45884
|
-
const run = [];
|
|
45885
|
-
for (const tok of tokens) {
|
|
45886
|
-
const good = GOOD_TOKEN.test(tok) && !NOISE.has(tok);
|
|
45887
|
-
if (!good) {
|
|
45888
|
-
if (run.length)
|
|
45889
|
-
break;
|
|
46226
|
+
function parseTransactions(items, metadata) {
|
|
46227
|
+
const dataItems = items.filter((i2) => i2.y > Y_DATA_MIN);
|
|
46228
|
+
const rows = buildRows(dataItems);
|
|
46229
|
+
const transactions = [];
|
|
46230
|
+
for (const row of rows) {
|
|
46231
|
+
const dateCell = row.find((c2) => c2.x <= X_DATE_MAX);
|
|
46232
|
+
if (!dateCell)
|
|
45890
46233
|
continue;
|
|
45891
|
-
|
|
45892
|
-
if (
|
|
45893
|
-
|
|
45894
|
-
|
|
45895
|
-
if (
|
|
45896
|
-
|
|
46234
|
+
const date4 = parseAmexPdfDate(dateCell.str.trim(), metadata);
|
|
46235
|
+
if (!date4)
|
|
46236
|
+
continue;
|
|
46237
|
+
const descCells = row.filter((c2) => c2.x >= X_DESC_MIN && c2.x < X_DESC_MAX);
|
|
46238
|
+
if (descCells.length === 0)
|
|
46239
|
+
continue;
|
|
46240
|
+
const description = descCells.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" ").replace(/\s{2,}/g, " ").trim();
|
|
46241
|
+
if (!description)
|
|
46242
|
+
continue;
|
|
46243
|
+
const amountCells = row.filter((c2) => c2.x >= X_AMOUNT_MIN);
|
|
46244
|
+
const isCredit = amountCells.some((c2) => c2.str.trim() === "CR");
|
|
46245
|
+
const amountCell = amountCells.find((c2) => c2.str.trim() !== "CR");
|
|
46246
|
+
if (!amountCell)
|
|
46247
|
+
continue;
|
|
46248
|
+
const amount = parseAmount(amountCell.str);
|
|
46249
|
+
if (amount === 0)
|
|
46250
|
+
continue;
|
|
46251
|
+
const type = isCredit ? "credit" : "debit";
|
|
46252
|
+
transactions.push({
|
|
46253
|
+
date: date4,
|
|
46254
|
+
description,
|
|
46255
|
+
amount,
|
|
46256
|
+
type,
|
|
46257
|
+
referenceNumber: null,
|
|
46258
|
+
valueDate: null,
|
|
46259
|
+
closingBalance: null,
|
|
46260
|
+
paymentMethod: null,
|
|
46261
|
+
extractedName: description,
|
|
46262
|
+
upiId: null,
|
|
46263
|
+
sourceCategory: null
|
|
46264
|
+
});
|
|
45897
46265
|
}
|
|
45898
|
-
return
|
|
46266
|
+
return transactions;
|
|
45899
46267
|
}
|
|
45900
|
-
|
|
45901
|
-
|
|
45902
|
-
const
|
|
45903
|
-
|
|
45904
|
-
|
|
45905
|
-
const key
|
|
45906
|
-
|
|
45907
|
-
|
|
45908
|
-
|
|
45909
|
-
|
|
45910
|
-
|
|
45911
|
-
|
|
46268
|
+
function buildRows(items) {
|
|
46269
|
+
const buckets = new Map;
|
|
46270
|
+
for (const item of items) {
|
|
46271
|
+
const pagePrefix = `${item.page}:`;
|
|
46272
|
+
let bucketKey = null;
|
|
46273
|
+
for (const key of buckets.keys()) {
|
|
46274
|
+
if (!key.startsWith(pagePrefix))
|
|
46275
|
+
continue;
|
|
46276
|
+
const bucketY = parseInt(key.slice(pagePrefix.length), 10);
|
|
46277
|
+
if (Math.abs(item.y - bucketY) <= ROW_TOLERANCE) {
|
|
46278
|
+
bucketKey = key;
|
|
46279
|
+
break;
|
|
46280
|
+
}
|
|
46281
|
+
}
|
|
46282
|
+
if (bucketKey === null) {
|
|
46283
|
+
bucketKey = `${item.page}:${item.y}`;
|
|
46284
|
+
buckets.set(bucketKey, [item]);
|
|
46285
|
+
} else {
|
|
46286
|
+
const existing = buckets.get(bucketKey);
|
|
46287
|
+
if (existing)
|
|
46288
|
+
existing.push(item);
|
|
46289
|
+
}
|
|
46290
|
+
}
|
|
46291
|
+
return [...buckets.entries()].sort((a2, b2) => {
|
|
46292
|
+
const [pa, ya] = a2[0].split(":").map(Number);
|
|
46293
|
+
const [pb, yb] = b2[0].split(":").map(Number);
|
|
46294
|
+
return pa !== pb ? pa - pb : yb - ya;
|
|
46295
|
+
}).map(([, row]) => row.sort((r1, r2) => r1.x - r2.x));
|
|
46296
|
+
}
|
|
46297
|
+
var MONTH_MAP = {
|
|
46298
|
+
january: 1,
|
|
46299
|
+
february: 2,
|
|
46300
|
+
march: 3,
|
|
46301
|
+
april: 4,
|
|
46302
|
+
may: 5,
|
|
46303
|
+
june: 6,
|
|
46304
|
+
july: 7,
|
|
46305
|
+
august: 8,
|
|
46306
|
+
september: 9,
|
|
46307
|
+
october: 10,
|
|
46308
|
+
november: 11,
|
|
46309
|
+
december: 12
|
|
46310
|
+
};
|
|
46311
|
+
function parseMonthDayYear(raw) {
|
|
46312
|
+
const match = raw.trim().match(/^(\w+)\s+(\d{1,2}),?\s+(\d{4})$/);
|
|
46313
|
+
if (!match)
|
|
46314
|
+
return null;
|
|
46315
|
+
const month = MONTH_MAP[match[1].toLowerCase()];
|
|
46316
|
+
if (!month)
|
|
46317
|
+
return null;
|
|
46318
|
+
const day = parseInt(match[2], 10);
|
|
46319
|
+
const year = parseInt(match[3], 10);
|
|
46320
|
+
const iso = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
|
46321
|
+
return { month, day, year, iso };
|
|
46322
|
+
}
|
|
46323
|
+
function parseMonthDay(raw) {
|
|
46324
|
+
const match = raw.trim().match(/^(\w+)\s+(\d{1,2})$/);
|
|
46325
|
+
if (!match)
|
|
46326
|
+
return null;
|
|
46327
|
+
const month = MONTH_MAP[match[1].toLowerCase()];
|
|
46328
|
+
if (!month)
|
|
46329
|
+
return null;
|
|
46330
|
+
return { month, day: parseInt(match[2], 10) };
|
|
45912
46331
|
}
|
|
45913
|
-
function
|
|
45914
|
-
|
|
46332
|
+
function parseAmexPdfDate(raw, meta2) {
|
|
46333
|
+
const parsed = parseMonthDay(raw);
|
|
46334
|
+
if (!parsed)
|
|
46335
|
+
return null;
|
|
46336
|
+
const year = parsed.month > meta2.periodEndMonth ? meta2.periodEndYear - 1 : meta2.periodEndYear;
|
|
46337
|
+
return `${year}-${String(parsed.month).padStart(2, "0")}-${String(parsed.day).padStart(2, "0")}`;
|
|
45915
46338
|
}
|
|
45916
|
-
|
|
45917
|
-
|
|
45918
|
-
|
|
45919
|
-
|
|
45920
|
-
|
|
45921
|
-
|
|
45922
|
-
return (await fetchAllRows(client, crypto3, params)).filter(isCategorizable);
|
|
46339
|
+
function parseAmount(raw) {
|
|
46340
|
+
if (!raw)
|
|
46341
|
+
return 0;
|
|
46342
|
+
const cleaned = raw.replace(/,/g, "").trim();
|
|
46343
|
+
const num = parseFloat(cleaned);
|
|
46344
|
+
return Number.isNaN(num) ? 0 : Math.abs(num);
|
|
45923
46345
|
}
|
|
45924
|
-
|
|
45925
|
-
|
|
45926
|
-
|
|
45927
|
-
|
|
45928
|
-
|
|
45929
|
-
|
|
45930
|
-
|
|
45931
|
-
|
|
45932
|
-
|
|
45933
|
-
|
|
45934
|
-
|
|
45935
|
-
|
|
45936
|
-
|
|
45937
|
-
|
|
45938
|
-
|
|
45939
|
-
|
|
45940
|
-
|
|
45941
|
-
|
|
46346
|
+
// ../parsers/src/classify.ts
|
|
46347
|
+
var CORPORATE_TOKENS = [
|
|
46348
|
+
"PVT",
|
|
46349
|
+
"PRIVATE",
|
|
46350
|
+
"LIMITED",
|
|
46351
|
+
"LTD",
|
|
46352
|
+
"LLP",
|
|
46353
|
+
"INC",
|
|
46354
|
+
"CORP",
|
|
46355
|
+
"ENTERPRISES",
|
|
46356
|
+
"TECHNOLOGIES",
|
|
46357
|
+
"SOLUTIONS",
|
|
46358
|
+
"SERVICES"
|
|
46359
|
+
];
|
|
46360
|
+
var CORPORATE_REGEX = new RegExp(`\\b(${CORPORATE_TOKENS.join("|")})\\b`);
|
|
46361
|
+
function classifyEntity({ paymentMethod, extractedName }) {
|
|
46362
|
+
if (paymentMethod === "atm")
|
|
46363
|
+
return "merchant";
|
|
46364
|
+
if (paymentMethod === "pos")
|
|
46365
|
+
return "merchant";
|
|
46366
|
+
const upper = (extractedName ?? "").toUpperCase();
|
|
46367
|
+
if (upper && CORPORATE_REGEX.test(upper))
|
|
46368
|
+
return "merchant";
|
|
46369
|
+
if (paymentMethod === "imps")
|
|
46370
|
+
return "person";
|
|
46371
|
+
if (paymentMethod === "upi")
|
|
46372
|
+
return "person";
|
|
46373
|
+
if (paymentMethod === "neft" || paymentMethod === "rtgs")
|
|
46374
|
+
return "merchant";
|
|
46375
|
+
if (paymentMethod === "ach")
|
|
46376
|
+
return "merchant";
|
|
46377
|
+
return "unknown";
|
|
46378
|
+
}
|
|
46379
|
+
// ../parsers/src/lib/parse-helpers.ts
|
|
46380
|
+
function parseAmount2(cell) {
|
|
46381
|
+
if (cell === null || cell === undefined || cell === "")
|
|
46382
|
+
return 0;
|
|
46383
|
+
if (typeof cell === "number")
|
|
46384
|
+
return Math.abs(cell);
|
|
46385
|
+
const cleaned = String(cell).replace(/,/g, "").trim();
|
|
46386
|
+
const num = parseFloat(cleaned);
|
|
46387
|
+
return Number.isNaN(num) ? 0 : Math.abs(num);
|
|
46388
|
+
}
|
|
46389
|
+
|
|
46390
|
+
// ../parsers/src/hdfc/narration.ts
|
|
46391
|
+
function parseHdfcNarration(narration) {
|
|
46392
|
+
const trimmed = narration.trim();
|
|
46393
|
+
if (trimmed.startsWith("UPI-")) {
|
|
46394
|
+
return parseUpi(trimmed);
|
|
45942
46395
|
}
|
|
45943
|
-
if (
|
|
45944
|
-
|
|
45945
|
-
pattern: p2.group.pattern,
|
|
45946
|
-
type: p2.group.type,
|
|
45947
|
-
rows: p2.group.rows.length,
|
|
45948
|
-
category: p2.slug
|
|
45949
|
-
}));
|
|
45950
|
-
return result;
|
|
46396
|
+
if (trimmed.startsWith("NEFT CR-") || trimmed.startsWith("NEFT DR-")) {
|
|
46397
|
+
return parseNeft(trimmed);
|
|
45951
46398
|
}
|
|
45952
|
-
if (
|
|
45953
|
-
return
|
|
45954
|
-
const learnable = plan.filter((p2) => p2.learn && p2.group.pattern !== UNPARSED);
|
|
45955
|
-
const merchantByRawId = new Map;
|
|
45956
|
-
if (learnable.length > 0) {
|
|
45957
|
-
const ms = await client.get("/api/merchants");
|
|
45958
|
-
for (const m2 of ms.data ?? [])
|
|
45959
|
-
if (m2.rawId && m2.householdId)
|
|
45960
|
-
merchantByRawId.set(m2.rawId, m2.id);
|
|
46399
|
+
if (trimmed.startsWith("ACH D-") || trimmed.startsWith("ACH C-")) {
|
|
46400
|
+
return parseAch(trimmed);
|
|
45961
46401
|
}
|
|
45962
|
-
|
|
45963
|
-
|
|
45964
|
-
const links = [];
|
|
45965
|
-
const direct = [];
|
|
45966
|
-
for (const { group, categoryId, slug, learn } of plan) {
|
|
45967
|
-
result.applied.push({
|
|
45968
|
-
pattern: group.pattern,
|
|
45969
|
-
type: group.type,
|
|
45970
|
-
rows: group.rows.length,
|
|
45971
|
-
category: slug
|
|
45972
|
-
});
|
|
45973
|
-
if (!learn || group.pattern === UNPARSED) {
|
|
45974
|
-
if (learn)
|
|
45975
|
-
result.unlearnable.push(UNPARSED);
|
|
45976
|
-
direct.push({ ids: group.rows.map((r2) => r2.id), categoryId });
|
|
45977
|
-
continue;
|
|
45978
|
-
}
|
|
45979
|
-
const rawId = `pattern:${group.pattern}`;
|
|
45980
|
-
let merchantId = merchantByRawId.get(rawId);
|
|
45981
|
-
if (!merchantId) {
|
|
45982
|
-
const created = await client.post("/api/merchants", {
|
|
45983
|
-
rawId,
|
|
45984
|
-
cleanName: titleCase(group.pattern),
|
|
45985
|
-
categoryId
|
|
45986
|
-
});
|
|
45987
|
-
merchantId = created.data.id;
|
|
45988
|
-
merchantByRawId.set(rawId, merchantId);
|
|
45989
|
-
}
|
|
45990
|
-
if (!bag.merchantAliases.some((al) => al.pattern === group.pattern)) {
|
|
45991
|
-
bag.merchantAliases.push({ pattern: group.pattern, merchantId, createdAt: now });
|
|
45992
|
-
}
|
|
45993
|
-
bag.merchantRules[merchantId] = { categoryId, lastUserCategoryAt: now };
|
|
45994
|
-
result.rulesSaved += 1;
|
|
45995
|
-
const withPerson = group.rows.filter((r2) => r2.personId);
|
|
45996
|
-
if (withPerson.length)
|
|
45997
|
-
direct.push({ ids: withPerson.map((r2) => r2.id), categoryId });
|
|
45998
|
-
for (const r2 of group.rows) {
|
|
45999
|
-
if (!r2.personId)
|
|
46000
|
-
links.push({ transactionId: r2.id, merchantId, categoryId });
|
|
46001
|
-
}
|
|
46402
|
+
if (trimmed.startsWith("FT-") || trimmed.startsWith("FT -")) {
|
|
46403
|
+
return parseFt(trimmed);
|
|
46002
46404
|
}
|
|
46003
|
-
if (
|
|
46004
|
-
|
|
46005
|
-
await postLinks(client, links);
|
|
46006
|
-
for (const { ids, categoryId } of direct) {
|
|
46007
|
-
const categoryType = [...catIndex.byId.entries()].find(([id]) => id === categoryId)?.[1].type;
|
|
46008
|
-
for (let i2 = 0;i2 < ids.length; i2 += 200) {
|
|
46009
|
-
await client.patch("/api/transactions/bulk", {
|
|
46010
|
-
ids: ids.slice(i2, i2 + 200),
|
|
46011
|
-
categoryId,
|
|
46012
|
-
...categoryType ? { categoryType } : {}
|
|
46013
|
-
});
|
|
46014
|
-
}
|
|
46405
|
+
if (trimmed.startsWith("IMPS-") || trimmed.startsWith("IMPS/")) {
|
|
46406
|
+
return parseImps(trimmed);
|
|
46015
46407
|
}
|
|
46016
|
-
|
|
46017
|
-
|
|
46018
|
-
|
|
46019
|
-
|
|
46020
|
-
|
|
46021
|
-
|
|
46022
|
-
|
|
46023
|
-
|
|
46024
|
-
|
|
46025
|
-
|
|
46026
|
-
|
|
46027
|
-
throw new Error("Jev categorization is disabled in private mode: it would send transaction text to a third party. " + "To opt in for your own account, set JEV_ALLOW_PRIVATE_MODE=1 in the MCP's environment. " + "Otherwise use get_uncategorized_groups + categorize_groups.");
|
|
46408
|
+
if (trimmed.startsWith("ATW-") || trimmed.startsWith("ATM-") || trimmed.startsWith("ATM/")) {
|
|
46409
|
+
return {
|
|
46410
|
+
paymentMethod: "atm",
|
|
46411
|
+
name: "ATM Withdrawal",
|
|
46412
|
+
upiId: null,
|
|
46413
|
+
referenceNumber: null,
|
|
46414
|
+
description: trimmed
|
|
46415
|
+
};
|
|
46416
|
+
}
|
|
46417
|
+
if (trimmed.startsWith("POS ")) {
|
|
46418
|
+
return parsePos(trimmed);
|
|
46028
46419
|
}
|
|
46420
|
+
return parseFallback(trimmed);
|
|
46029
46421
|
}
|
|
46030
|
-
function
|
|
46031
|
-
|
|
46032
|
-
const
|
|
46033
|
-
if (
|
|
46034
|
-
|
|
46035
|
-
|
|
46422
|
+
function parseUpi(narration) {
|
|
46423
|
+
const rest = narration.slice(4);
|
|
46424
|
+
const parts = rest.split("-");
|
|
46425
|
+
if (parts.length < 4) {
|
|
46426
|
+
return {
|
|
46427
|
+
paymentMethod: "upi",
|
|
46428
|
+
name: rest,
|
|
46429
|
+
upiId: null,
|
|
46430
|
+
referenceNumber: null,
|
|
46431
|
+
description: null
|
|
46432
|
+
};
|
|
46433
|
+
}
|
|
46434
|
+
const name = parts[0].trim();
|
|
46435
|
+
const upiId = parts[1]?.trim() || null;
|
|
46436
|
+
const referenceNumber = parts[3]?.trim() || null;
|
|
46437
|
+
const description = parts.length > 4 ? parts.slice(4).join("-").trim() : null;
|
|
46438
|
+
return {
|
|
46439
|
+
paymentMethod: "upi",
|
|
46440
|
+
name: cleanName(name),
|
|
46441
|
+
upiId,
|
|
46442
|
+
referenceNumber,
|
|
46443
|
+
description: description || null
|
|
46444
|
+
};
|
|
46036
46445
|
}
|
|
46037
|
-
function
|
|
46038
|
-
const
|
|
46039
|
-
|
|
46040
|
-
|
|
46041
|
-
|
|
46042
|
-
|
|
46043
|
-
|
|
46044
|
-
|
|
46045
|
-
|
|
46046
|
-
|
|
46047
|
-
|
|
46446
|
+
function parseNeft(narration) {
|
|
46447
|
+
const isCredit = narration.startsWith("NEFT CR-");
|
|
46448
|
+
const rest = narration.slice(isCredit ? 8 : 8);
|
|
46449
|
+
const parts = rest.split("-");
|
|
46450
|
+
if (parts.length < 2) {
|
|
46451
|
+
return {
|
|
46452
|
+
paymentMethod: "neft",
|
|
46453
|
+
name: rest,
|
|
46454
|
+
upiId: null,
|
|
46455
|
+
referenceNumber: null,
|
|
46456
|
+
description: null
|
|
46457
|
+
};
|
|
46458
|
+
}
|
|
46459
|
+
const companyName = parts[1]?.trim() || parts[0]?.trim() || rest;
|
|
46460
|
+
const referenceNumber = parts[parts.length - 1]?.trim() || null;
|
|
46461
|
+
return {
|
|
46462
|
+
paymentMethod: "neft",
|
|
46463
|
+
name: cleanName(companyName),
|
|
46464
|
+
upiId: null,
|
|
46465
|
+
referenceNumber,
|
|
46466
|
+
description: null
|
|
46048
46467
|
};
|
|
46049
46468
|
}
|
|
46050
|
-
|
|
46051
|
-
|
|
46052
|
-
|
|
46469
|
+
function parseAch(narration) {
|
|
46470
|
+
const rest = narration.slice(7).trim();
|
|
46471
|
+
const parts = rest.split("-");
|
|
46472
|
+
const name = parts[0]?.trim() || rest;
|
|
46473
|
+
const referenceNumber = parts.length > 1 ? parts[parts.length - 1]?.trim() : null;
|
|
46474
|
+
return {
|
|
46475
|
+
paymentMethod: "ach",
|
|
46476
|
+
name: cleanName(name),
|
|
46477
|
+
upiId: null,
|
|
46478
|
+
referenceNumber,
|
|
46479
|
+
description: null
|
|
46480
|
+
};
|
|
46053
46481
|
}
|
|
46054
|
-
function
|
|
46055
|
-
const
|
|
46056
|
-
const
|
|
46057
|
-
|
|
46058
|
-
|
|
46059
|
-
|
|
46060
|
-
|
|
46482
|
+
function parseFt(narration) {
|
|
46483
|
+
const rest = narration.replace(/^FT[\s-]+/, "").trim();
|
|
46484
|
+
const spaceDashParts = rest.split(" - ");
|
|
46485
|
+
if (spaceDashParts.length >= 2) {
|
|
46486
|
+
const namePart = spaceDashParts.find((p2, i2) => i2 > 0 && p2.trim().length > 0);
|
|
46487
|
+
return {
|
|
46488
|
+
paymentMethod: "fund_transfer",
|
|
46489
|
+
name: cleanName(namePart?.trim() || rest),
|
|
46490
|
+
upiId: null,
|
|
46491
|
+
referenceNumber: spaceDashParts[0]?.split("-")[0]?.trim() || null,
|
|
46492
|
+
description: null
|
|
46493
|
+
};
|
|
46061
46494
|
}
|
|
46062
|
-
|
|
46063
|
-
|
|
46495
|
+
return {
|
|
46496
|
+
paymentMethod: "fund_transfer",
|
|
46497
|
+
name: cleanName(rest),
|
|
46498
|
+
upiId: null,
|
|
46499
|
+
referenceNumber: null,
|
|
46500
|
+
description: null
|
|
46501
|
+
};
|
|
46064
46502
|
}
|
|
46065
|
-
function
|
|
46066
|
-
const
|
|
46067
|
-
|
|
46503
|
+
function parseImps(narration) {
|
|
46504
|
+
const rest = narration.replace(/^IMPS[-/]/, "").trim();
|
|
46505
|
+
const parts = rest.split("-");
|
|
46506
|
+
const referenceNumber = parts[0]?.trim() || null;
|
|
46507
|
+
const name = parts.length > 1 ? parts[1]?.trim() : rest;
|
|
46508
|
+
return {
|
|
46509
|
+
paymentMethod: "imps",
|
|
46510
|
+
name: cleanName(name || rest),
|
|
46511
|
+
upiId: null,
|
|
46512
|
+
referenceNumber,
|
|
46513
|
+
description: null
|
|
46514
|
+
};
|
|
46068
46515
|
}
|
|
46069
|
-
|
|
46070
|
-
const
|
|
46071
|
-
|
|
46072
|
-
|
|
46073
|
-
|
|
46074
|
-
|
|
46075
|
-
|
|
46076
|
-
|
|
46077
|
-
|
|
46078
|
-
|
|
46516
|
+
function parsePos(narration) {
|
|
46517
|
+
const rest = narration.slice(4).trim();
|
|
46518
|
+
const cleaned = rest.replace(/^[0-9X]{4,}\s*/, "").trim();
|
|
46519
|
+
return {
|
|
46520
|
+
paymentMethod: "pos",
|
|
46521
|
+
name: cleanName(cleaned || rest),
|
|
46522
|
+
upiId: null,
|
|
46523
|
+
referenceNumber: null,
|
|
46524
|
+
description: null
|
|
46525
|
+
};
|
|
46079
46526
|
}
|
|
46080
|
-
|
|
46081
|
-
assertPrivateModeAllowed(crypto3, opts.env);
|
|
46082
|
-
const min = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
|
|
46083
|
-
const index = await loadCategoryIndex(client);
|
|
46084
|
-
const rows = await fetchUncategorized(client, crypto3, opts);
|
|
46085
|
-
const groups = groupRows(rows);
|
|
46086
|
-
const unparsed = groups.filter((g3) => g3.pattern === UNPARSED).length;
|
|
46087
|
-
const candidates = groups.filter((g3) => g3.pattern !== UNPARSED);
|
|
46088
|
-
let errors4 = 0;
|
|
46089
|
-
const suggestions = await mapLimit(candidates, CONCURRENCY, async (g3) => {
|
|
46090
|
-
try {
|
|
46091
|
-
const options = optionsFor(g3.type, index);
|
|
46092
|
-
const sample = g3.rows[0].description;
|
|
46093
|
-
const text = crypto3 ? sanitizeNarration(sample) : sample;
|
|
46094
|
-
const r2 = await classify(groupState(g3.type, text), options);
|
|
46095
|
-
return r2.choice in options ? { g: g3, ...r2 } : null;
|
|
46096
|
-
} catch {
|
|
46097
|
-
errors4 += 1;
|
|
46098
|
-
return null;
|
|
46099
|
-
}
|
|
46100
|
-
});
|
|
46101
|
-
const accepted = [];
|
|
46102
|
-
const review = [];
|
|
46103
|
-
for (const s2 of suggestions) {
|
|
46104
|
-
if (!s2)
|
|
46105
|
-
continue;
|
|
46106
|
-
const isTransfer = index.bySlug.get(s2.choice)?.type === "transfer";
|
|
46107
|
-
if (s2.choice !== UNSURE && !isTransfer && s2.confidence >= min) {
|
|
46108
|
-
accepted.push({
|
|
46109
|
-
pattern: s2.g.pattern,
|
|
46110
|
-
type: s2.g.type,
|
|
46111
|
-
categorySlug: s2.choice,
|
|
46112
|
-
learn: s2.g.pattern.length >= MIN_LEARN_PATTERN_LEN,
|
|
46113
|
-
confidence: s2.confidence
|
|
46114
|
-
});
|
|
46115
|
-
} else {
|
|
46116
|
-
review.push({
|
|
46117
|
-
pattern: s2.g.pattern,
|
|
46118
|
-
type: s2.g.type === "credit" ? "cr" : "dr",
|
|
46119
|
-
best: s2.choice,
|
|
46120
|
-
confidence: Math.round(s2.confidence * 100) / 100,
|
|
46121
|
-
total: Math.round(s2.g.total)
|
|
46122
|
-
});
|
|
46123
|
-
}
|
|
46124
|
-
}
|
|
46125
|
-
review.sort((a2, b2) => b2.total - a2.total);
|
|
46126
|
-
const applied = await applyGroupAssignments(client, crypto3, accepted.map(({ confidence: _c, ...a2 }) => a2), { dryRun: opts.dryRun, startDate: opts.startDate, endDate: opts.endDate });
|
|
46127
|
-
const conf = new Map(accepted.map((a2) => [`${a2.pattern}|${a2.type}`, a2.confidence]));
|
|
46527
|
+
function parseFallback(narration) {
|
|
46128
46528
|
return {
|
|
46129
|
-
|
|
46130
|
-
|
|
46131
|
-
|
|
46132
|
-
|
|
46133
|
-
|
|
46134
|
-
rows: a2.rows
|
|
46135
|
-
})),
|
|
46136
|
-
review: review.slice(0, 30),
|
|
46137
|
-
skipped: { unparsed, errors: errors4 },
|
|
46138
|
-
rulesSaved: applied.rulesSaved
|
|
46529
|
+
paymentMethod: "other",
|
|
46530
|
+
name: cleanName(narration),
|
|
46531
|
+
upiId: null,
|
|
46532
|
+
referenceNumber: null,
|
|
46533
|
+
description: null
|
|
46139
46534
|
};
|
|
46140
46535
|
}
|
|
46141
|
-
|
|
46142
|
-
|
|
46143
|
-
function registerJevTools(server, client, crypto3) {
|
|
46144
|
-
server.registerTool("auto_categorize_jev", {
|
|
46145
|
-
description: "Auto-categorize uncategorized transactions with Jev (TypeSafe's classifier) — zero LLM tokens, ~ms per group. " + "OPT-IN: needs JEV_API_KEY. Refuses in private mode (it would send transaction text to a third party) unless the owner sets JEV_ALLOW_PRIVATE_MODE=1; in private mode only the sanitized merchant pattern is sent. " + "Groups by merchant pattern; groups at/above `minConfidence` (default 0.85) are categorized and a reusable rule is saved; " + "the rest come back in `review` (top 30 by ₹) for you to decide with `categorize_groups`. Use dryRun to preview.",
|
|
46146
|
-
inputSchema: {
|
|
46147
|
-
startDate: exports_external.string().optional().describe("YYYY-MM-DD"),
|
|
46148
|
-
endDate: exports_external.string().optional().describe("YYYY-MM-DD"),
|
|
46149
|
-
minConfidence: exports_external.number().min(0.5).max(1).optional().describe(`Apply at/above this confidence (default ${DEFAULT_MIN_CONFIDENCE})`),
|
|
46150
|
-
dryRun: exports_external.boolean().optional()
|
|
46151
|
-
}
|
|
46152
|
-
}, async ({ startDate, endDate, minConfidence, dryRun }) => {
|
|
46153
|
-
const key = assertJevAllowed(crypto3);
|
|
46154
|
-
const result = await jevCategorize(client, crypto3, makeJevClassifier(key), {
|
|
46155
|
-
startDate,
|
|
46156
|
-
endDate,
|
|
46157
|
-
minConfidence,
|
|
46158
|
-
dryRun
|
|
46159
|
-
});
|
|
46160
|
-
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
46161
|
-
});
|
|
46536
|
+
function cleanName(raw) {
|
|
46537
|
+
return raw.replace(/\s+(PRIVATE|PVT\.?)\s*\w*/gi, "").replace(/\s+(LIMITED|LTD\.?)(\s|$)/gi, " ").replace(/[\s.-]+$/, "").replace(/\s{2,}/g, " ").trim();
|
|
46162
46538
|
}
|
|
46163
46539
|
|
|
46164
|
-
// src/
|
|
46165
|
-
|
|
46166
|
-
const
|
|
46167
|
-
const
|
|
46168
|
-
|
|
46169
|
-
|
|
46170
|
-
|
|
46171
|
-
|
|
46172
|
-
|
|
46173
|
-
|
|
46174
|
-
|
|
46540
|
+
// ../parsers/src/sbi/savings.ts
|
|
46541
|
+
function parseSbiNarration(narration) {
|
|
46542
|
+
const upper = narration.toUpperCase();
|
|
46543
|
+
const upiMatch = narration.match(/UPI\/(?:DR|CR)\/(\d+)\/([^/]+)\/([A-Z]{2,6})\/([^/\s]+)/i);
|
|
46544
|
+
if (upiMatch) {
|
|
46545
|
+
const name = upiMatch[2].trim();
|
|
46546
|
+
const upiId = upiMatch[4].trim();
|
|
46547
|
+
return {
|
|
46548
|
+
paymentMethod: "upi",
|
|
46549
|
+
name: name || null,
|
|
46550
|
+
upiId: upiId || null
|
|
46551
|
+
};
|
|
46175
46552
|
}
|
|
46176
|
-
|
|
46177
|
-
|
|
46178
|
-
|
|
46179
|
-
|
|
46180
|
-
|
|
46181
|
-
|
|
46182
|
-
|
|
46183
|
-
|
|
46184
|
-
|
|
46185
|
-
|
|
46186
|
-
|
|
46187
|
-
|
|
46188
|
-
|
|
46553
|
+
if (upper.includes("NEFT")) {
|
|
46554
|
+
const neftMatch = narration.match(/NEFT[-/][\w]+[-/]([\w\s]+?)(?:[-/]|$)/i);
|
|
46555
|
+
return {
|
|
46556
|
+
paymentMethod: "neft",
|
|
46557
|
+
name: neftMatch ? neftMatch[1].trim() : null,
|
|
46558
|
+
upiId: null
|
|
46559
|
+
};
|
|
46560
|
+
}
|
|
46561
|
+
if (upper.includes("RTGS")) {
|
|
46562
|
+
return { paymentMethod: "rtgs", name: null, upiId: null };
|
|
46563
|
+
}
|
|
46564
|
+
if (upper.includes("IMPS")) {
|
|
46565
|
+
return { paymentMethod: "imps", name: null, upiId: null };
|
|
46566
|
+
}
|
|
46567
|
+
if (upper.includes("ATM") || upper.includes("WDL ATM")) {
|
|
46568
|
+
return { paymentMethod: "atm", name: null, upiId: null };
|
|
46569
|
+
}
|
|
46570
|
+
if (upper.includes("POS") || upper.includes("PURCHASE")) {
|
|
46571
|
+
return { paymentMethod: "pos", name: null, upiId: null };
|
|
46572
|
+
}
|
|
46573
|
+
if (upper.includes("INT.COLL") || upper.includes("INTEREST")) {
|
|
46574
|
+
return { paymentMethod: "other", name: "Interest", upiId: null };
|
|
46575
|
+
}
|
|
46576
|
+
return { paymentMethod: null, name: null, upiId: null };
|
|
46577
|
+
}
|
|
46578
|
+
// ../parsers/src/hdfc/cc-pdf.ts
|
|
46579
|
+
var _pdfjs2 = null;
|
|
46580
|
+
async function getPdfjs2() {
|
|
46581
|
+
if (!_pdfjs2) {
|
|
46582
|
+
_pdfjs2 = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
46583
|
+
_pdfjs2.GlobalWorkerOptions.workerSrc = import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs");
|
|
46584
|
+
}
|
|
46585
|
+
return _pdfjs2;
|
|
46586
|
+
}
|
|
46587
|
+
var NOISE = /^(C|l|EMI|PI)$/;
|
|
46588
|
+
var NEUCOIN = /^[+-]\s*[\d,]/;
|
|
46589
|
+
var DATE_CELL = /^\d{2}\/\d{2}\/\d{4}/;
|
|
46590
|
+
var AMOUNT_CELL = /^[\d,]+\.\d{2}$/;
|
|
46591
|
+
var CREDIT_CELL = /^\+$/;
|
|
46592
|
+
var ROW_TOLERANCE2 = 4;
|
|
46593
|
+
async function parseHdfcCreditCardPdf(data, password) {
|
|
46594
|
+
const pdfjs = await getPdfjs2();
|
|
46595
|
+
const loadOptions = {
|
|
46596
|
+
data: new Uint8Array(data),
|
|
46597
|
+
useWorkerFetch: false,
|
|
46598
|
+
isEvalSupported: false,
|
|
46599
|
+
useSystemFonts: true
|
|
46600
|
+
};
|
|
46601
|
+
if (password)
|
|
46602
|
+
loadOptions.password = password;
|
|
46603
|
+
const pdf = await pdfjs.getDocument(loadOptions).promise;
|
|
46604
|
+
const items = await extractTextItems2(pdf);
|
|
46605
|
+
const metadata = extractMetadata2(items);
|
|
46606
|
+
const transactions = parseTransactions2(items);
|
|
46607
|
+
return {
|
|
46608
|
+
bank: "hdfc",
|
|
46609
|
+
accountType: "credit_card",
|
|
46610
|
+
accountNumber: metadata.accountLast4,
|
|
46611
|
+
periodStart: metadata.periodStart,
|
|
46612
|
+
periodEnd: metadata.periodEnd,
|
|
46613
|
+
transactions
|
|
46614
|
+
};
|
|
46615
|
+
}
|
|
46616
|
+
async function extractTextItems2(pdf) {
|
|
46617
|
+
const result = [];
|
|
46618
|
+
for (let p2 = 1;p2 <= pdf.numPages; p2++) {
|
|
46619
|
+
const page = await pdf.getPage(p2);
|
|
46620
|
+
const content = await page.getTextContent();
|
|
46621
|
+
for (const item of content.items) {
|
|
46622
|
+
const ti = item;
|
|
46623
|
+
if (typeof ti.str !== "string" || !ti.str.trim())
|
|
46624
|
+
continue;
|
|
46625
|
+
result.push({
|
|
46626
|
+
page: p2,
|
|
46627
|
+
x: Math.round(ti.transform[4]),
|
|
46628
|
+
y: Math.round(ti.transform[5]),
|
|
46629
|
+
str: ti.str
|
|
46630
|
+
});
|
|
46189
46631
|
}
|
|
46190
|
-
}
|
|
46191
|
-
|
|
46192
|
-
|
|
46193
|
-
|
|
46194
|
-
|
|
46195
|
-
|
|
46196
|
-
|
|
46632
|
+
}
|
|
46633
|
+
return result;
|
|
46634
|
+
}
|
|
46635
|
+
function extractMetadata2(items) {
|
|
46636
|
+
const fullText = items.map((i2) => i2.str).join(" ");
|
|
46637
|
+
const cardMatch = fullText.match(/(\d{6}X{6}\d{4})/);
|
|
46638
|
+
const accountLast4 = cardMatch ? cardMatch[1].slice(-4) : null;
|
|
46639
|
+
const DATE_PART = /\d{1,2}\s+\w+,?\s+\d{4}/;
|
|
46640
|
+
const periodMatch = fullText.match(new RegExp(`(${DATE_PART.source})\\s*[-–]\\s*(${DATE_PART.source})`));
|
|
46641
|
+
let periodStart = null;
|
|
46642
|
+
let periodEnd = null;
|
|
46643
|
+
if (periodMatch) {
|
|
46644
|
+
periodStart = parseMonthNameDate(periodMatch[1]);
|
|
46645
|
+
periodEnd = parseMonthNameDate(periodMatch[2]);
|
|
46646
|
+
}
|
|
46647
|
+
return { accountLast4, periodStart, periodEnd };
|
|
46648
|
+
}
|
|
46649
|
+
function parseTransactions2(items) {
|
|
46650
|
+
const rows = buildRows2(items);
|
|
46651
|
+
const transactions = [];
|
|
46652
|
+
for (const row of rows) {
|
|
46653
|
+
const dateItem = row.find((c2) => DATE_CELL.test(c2.str.trim()));
|
|
46654
|
+
if (!dateItem)
|
|
46655
|
+
continue;
|
|
46656
|
+
const date4 = parseDdMmYyyy(dateItem.str);
|
|
46657
|
+
if (!date4)
|
|
46658
|
+
continue;
|
|
46659
|
+
const useful = row.filter((c2) => !NOISE.test(c2.str.trim()) && !NEUCOIN.test(c2.str.trim()));
|
|
46660
|
+
const amountItem = [...useful].reverse().find((c2) => AMOUNT_CELL.test(c2.str.trim()));
|
|
46661
|
+
if (!amountItem)
|
|
46662
|
+
continue;
|
|
46663
|
+
const amount = parseAmount2(amountItem.str);
|
|
46664
|
+
if (amount === 0)
|
|
46665
|
+
continue;
|
|
46666
|
+
const isCredit = useful.some((c2) => CREDIT_CELL.test(c2.str.trim()));
|
|
46667
|
+
const description = useful.filter((c2) => c2 !== dateItem && c2 !== amountItem && !CREDIT_CELL.test(c2.str.trim()) && c2.x < amountItem.x).sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" ").replace(/([a-z])([A-Z][a-z])/g, "$1 $2").replace(/\s{2,}/g, " ").trim();
|
|
46668
|
+
if (!description)
|
|
46669
|
+
continue;
|
|
46670
|
+
transactions.push({
|
|
46671
|
+
date: date4,
|
|
46672
|
+
description,
|
|
46673
|
+
amount,
|
|
46674
|
+
type: isCredit ? "credit" : "debit",
|
|
46675
|
+
referenceNumber: null,
|
|
46676
|
+
valueDate: null,
|
|
46677
|
+
closingBalance: null,
|
|
46678
|
+
paymentMethod: null,
|
|
46679
|
+
extractedName: description,
|
|
46680
|
+
upiId: null,
|
|
46681
|
+
sourceCategory: null
|
|
46682
|
+
});
|
|
46683
|
+
}
|
|
46684
|
+
return transactions;
|
|
46685
|
+
}
|
|
46686
|
+
function buildRows2(items) {
|
|
46687
|
+
const buckets = new Map;
|
|
46688
|
+
for (const item of items) {
|
|
46689
|
+
const pagePrefix = `${item.page}:`;
|
|
46690
|
+
let bucketKey = null;
|
|
46691
|
+
for (const key of buckets.keys()) {
|
|
46692
|
+
if (!key.startsWith(pagePrefix))
|
|
46693
|
+
continue;
|
|
46694
|
+
const bucketY = parseInt(key.slice(pagePrefix.length), 10);
|
|
46695
|
+
if (Math.abs(item.y - bucketY) <= ROW_TOLERANCE2) {
|
|
46696
|
+
bucketKey = key;
|
|
46697
|
+
break;
|
|
46197
46698
|
}
|
|
46198
|
-
payload.categoryId = categoryId;
|
|
46199
46699
|
}
|
|
46200
|
-
|
|
46201
|
-
|
|
46202
|
-
|
|
46203
|
-
|
|
46204
|
-
|
|
46205
|
-
|
|
46206
|
-
|
|
46700
|
+
if (bucketKey === null) {
|
|
46701
|
+
bucketKey = `${item.page}:${item.y}`;
|
|
46702
|
+
buckets.set(bucketKey, [item]);
|
|
46703
|
+
} else {
|
|
46704
|
+
const existing = buckets.get(bucketKey);
|
|
46705
|
+
if (existing)
|
|
46706
|
+
existing.push(item);
|
|
46707
|
+
}
|
|
46708
|
+
}
|
|
46709
|
+
return [...buckets.entries()].sort((a2, b2) => {
|
|
46710
|
+
const [pa, ya] = a2[0].split(":").map(Number);
|
|
46711
|
+
const [pb, yb] = b2[0].split(":").map(Number);
|
|
46712
|
+
return pa !== pb ? pa - pb : yb - ya;
|
|
46713
|
+
}).map(([, row]) => row.sort((a2, b2) => a2.x - b2.x));
|
|
46714
|
+
}
|
|
46715
|
+
var MONTH_NAMES = {
|
|
46716
|
+
jan: "01",
|
|
46717
|
+
feb: "02",
|
|
46718
|
+
mar: "03",
|
|
46719
|
+
apr: "04",
|
|
46720
|
+
may: "05",
|
|
46721
|
+
jun: "06",
|
|
46722
|
+
jul: "07",
|
|
46723
|
+
aug: "08",
|
|
46724
|
+
sep: "09",
|
|
46725
|
+
oct: "10",
|
|
46726
|
+
nov: "11",
|
|
46727
|
+
dec: "12"
|
|
46728
|
+
};
|
|
46729
|
+
function parseMonthNameDate(raw) {
|
|
46730
|
+
const match = raw.trim().match(/^(\d{1,2})\s+(\w{3})\w*,?\s+(\d{4})$/);
|
|
46731
|
+
if (!match)
|
|
46732
|
+
return null;
|
|
46733
|
+
const month = MONTH_NAMES[match[2].toLowerCase().slice(0, 3)];
|
|
46734
|
+
if (!month)
|
|
46735
|
+
return null;
|
|
46736
|
+
return `${match[3]}-${month}-${match[1].padStart(2, "0")}`;
|
|
46737
|
+
}
|
|
46738
|
+
function parseDdMmYyyy(raw) {
|
|
46739
|
+
const match = raw.trim().match(/^(\d{2})\/(\d{2})\/(\d{4})/);
|
|
46740
|
+
if (!match)
|
|
46741
|
+
return null;
|
|
46742
|
+
return `${match[3]}-${match[2]}-${match[1]}`;
|
|
46743
|
+
}
|
|
46744
|
+
// ../parsers/src/hdfc/savings-pdf.ts
|
|
46745
|
+
var _pdfjs3 = null;
|
|
46746
|
+
async function getPdfjs3() {
|
|
46747
|
+
if (!_pdfjs3) {
|
|
46748
|
+
_pdfjs3 = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
46749
|
+
_pdfjs3.GlobalWorkerOptions.workerSrc = import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs");
|
|
46750
|
+
}
|
|
46751
|
+
return _pdfjs3;
|
|
46752
|
+
}
|
|
46753
|
+
var X_DATE_MAX2 = 56;
|
|
46754
|
+
var X_NARRATION_MIN = 60;
|
|
46755
|
+
var X_NARRATION_MAX = 280;
|
|
46756
|
+
var X_REF_MIN = 280;
|
|
46757
|
+
var X_VALUEDT_MIN = 355;
|
|
46758
|
+
var X_WITHDRAWAL_MIN = 402;
|
|
46759
|
+
var X_WITHDRAWAL_MAX = 490;
|
|
46760
|
+
var X_DEPOSIT_MIN = 490;
|
|
46761
|
+
var X_DEPOSIT_MAX = 560;
|
|
46762
|
+
var X_BALANCE_MIN = 560;
|
|
46763
|
+
var Y_DATA_MAX = 618;
|
|
46764
|
+
var Y_DATA_MIN2 = 55;
|
|
46765
|
+
var ROW_TOLERANCE3 = 4;
|
|
46766
|
+
async function parseHdfcSavingsPdf(data, password) {
|
|
46767
|
+
const pdfjs = await getPdfjs3();
|
|
46768
|
+
const loadOptions = {
|
|
46769
|
+
data: new Uint8Array(data),
|
|
46770
|
+
useWorkerFetch: false,
|
|
46771
|
+
isEvalSupported: false,
|
|
46772
|
+
useSystemFonts: true
|
|
46773
|
+
};
|
|
46774
|
+
if (password)
|
|
46775
|
+
loadOptions.password = password;
|
|
46776
|
+
const pdf = await pdfjs.getDocument(loadOptions).promise;
|
|
46777
|
+
const items = await extractTextItems3(pdf);
|
|
46778
|
+
const metadata = extractMetadata3(items);
|
|
46779
|
+
const transactions = parseHdfcSavingsItems(items);
|
|
46780
|
+
return {
|
|
46781
|
+
bank: "hdfc",
|
|
46782
|
+
accountType: metadata.accountType,
|
|
46783
|
+
accountNumber: metadata.accountLast4,
|
|
46784
|
+
periodStart: metadata.periodStart,
|
|
46785
|
+
periodEnd: metadata.periodEnd,
|
|
46786
|
+
transactions
|
|
46787
|
+
};
|
|
46788
|
+
}
|
|
46789
|
+
async function extractTextItems3(pdf) {
|
|
46790
|
+
const result = [];
|
|
46791
|
+
for (let p2 = 1;p2 <= pdf.numPages; p2++) {
|
|
46792
|
+
const page = await pdf.getPage(p2);
|
|
46793
|
+
const content = await page.getTextContent();
|
|
46794
|
+
for (const item of content.items) {
|
|
46795
|
+
const ti = item;
|
|
46796
|
+
if (typeof ti.str !== "string" || !ti.str.trim())
|
|
46797
|
+
continue;
|
|
46798
|
+
result.push({
|
|
46799
|
+
page: p2,
|
|
46800
|
+
x: Math.round(ti.transform[4]),
|
|
46801
|
+
y: Math.round(ti.transform[5]),
|
|
46802
|
+
str: ti.str,
|
|
46803
|
+
w: Math.round(ti.width ?? 0)
|
|
46804
|
+
});
|
|
46207
46805
|
}
|
|
46208
|
-
}
|
|
46209
|
-
|
|
46210
|
-
|
|
46211
|
-
|
|
46212
|
-
|
|
46213
|
-
|
|
46214
|
-
|
|
46215
|
-
|
|
46216
|
-
|
|
46806
|
+
}
|
|
46807
|
+
return result;
|
|
46808
|
+
}
|
|
46809
|
+
function extractMetadata3(items) {
|
|
46810
|
+
const headerItems = items.filter((i2) => i2.y > Y_DATA_MAX);
|
|
46811
|
+
const rowStrings = groupIntoRowStrings2(headerItems);
|
|
46812
|
+
const fullText = rowStrings.join(" ");
|
|
46813
|
+
let accountType = "savings";
|
|
46814
|
+
let accountLast4 = null;
|
|
46815
|
+
let periodStart = null;
|
|
46816
|
+
let periodEnd = null;
|
|
46817
|
+
const accountMatch = fullText.match(/Account No\s*:?\s*(\d{10,18})/);
|
|
46818
|
+
if (accountMatch) {
|
|
46819
|
+
accountLast4 = accountMatch[1].slice(-4);
|
|
46820
|
+
}
|
|
46821
|
+
if (/current/i.test(fullText) && /account.*type/i.test(fullText)) {
|
|
46822
|
+
accountType = "current";
|
|
46823
|
+
}
|
|
46824
|
+
const periodMatch = fullText.match(/(?:Statement\s+)?From\s*:\s*(\d{2}\/\d{2}\/\d{4})\s+To\s*:\s*(\d{2}\/\d{2}\/\d{4})/i);
|
|
46825
|
+
if (periodMatch) {
|
|
46826
|
+
periodStart = parseHdfcDate(periodMatch[1]);
|
|
46827
|
+
periodEnd = parseHdfcDate(periodMatch[2]);
|
|
46828
|
+
}
|
|
46829
|
+
return { accountType, accountLast4, periodStart, periodEnd };
|
|
46830
|
+
}
|
|
46831
|
+
function groupIntoRowStrings2(items) {
|
|
46832
|
+
const buckets = new Map;
|
|
46833
|
+
for (const item of items) {
|
|
46834
|
+
const pagePrefix = `${item.page}:`;
|
|
46835
|
+
let bucketKey = null;
|
|
46836
|
+
for (const key of buckets.keys()) {
|
|
46837
|
+
if (!key.startsWith(pagePrefix))
|
|
46838
|
+
continue;
|
|
46839
|
+
if (Math.abs(item.y - parseInt(key.slice(pagePrefix.length), 10)) <= ROW_TOLERANCE3) {
|
|
46840
|
+
bucketKey = key;
|
|
46841
|
+
break;
|
|
46842
|
+
}
|
|
46843
|
+
}
|
|
46844
|
+
if (bucketKey === null) {
|
|
46845
|
+
bucketKey = `${item.page}:${item.y}`;
|
|
46846
|
+
buckets.set(bucketKey, [item]);
|
|
46847
|
+
} else {
|
|
46848
|
+
const existing = buckets.get(bucketKey);
|
|
46849
|
+
if (existing)
|
|
46850
|
+
existing.push(item);
|
|
46851
|
+
}
|
|
46852
|
+
}
|
|
46853
|
+
return [...buckets.values()].map((row) => row.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" "));
|
|
46854
|
+
}
|
|
46855
|
+
var COMPACT_DATE_MAX_X = 90;
|
|
46856
|
+
var COMPACT_NARRATION_MIN_X = 90;
|
|
46857
|
+
var COMPACT_NUMERIC_MIN_X = 300;
|
|
46858
|
+
var COMPACT_WITHDRAWAL_MAX_RIGHT = 400;
|
|
46859
|
+
var COMPACT_DEPOSIT_MAX_RIGHT = 500;
|
|
46860
|
+
var AMOUNT_RE = /^[\d,]+\.\d{2}$/;
|
|
46861
|
+
function detectLayout(items) {
|
|
46862
|
+
const hdr = items.find((i2) => /^Narration$/i.test(i2.str.trim()));
|
|
46863
|
+
if (!hdr)
|
|
46864
|
+
return "classic";
|
|
46865
|
+
const headerRow = items.filter((i2) => i2.page === hdr.page && Math.abs(i2.y - hdr.y) <= ROW_TOLERANCE3);
|
|
46866
|
+
const isClassic = headerRow.some((i2) => /chq|ref\.?\s*no|value\s*dt/i.test(i2.str));
|
|
46867
|
+
return isClassic ? "classic" : "compact";
|
|
46868
|
+
}
|
|
46869
|
+
function classifyRow(row, layout) {
|
|
46870
|
+
if (layout === "compact") {
|
|
46871
|
+
const amounts = row.filter((c2) => c2.x >= COMPACT_NUMERIC_MIN_X && AMOUNT_RE.test(c2.str.trim()));
|
|
46872
|
+
const right = (c2) => c2.x + c2.w;
|
|
46873
|
+
return {
|
|
46874
|
+
dateCell: row.find((c2) => c2.x <= COMPACT_DATE_MAX_X),
|
|
46875
|
+
narrationCells: row.filter((c2) => c2.x > COMPACT_NARRATION_MIN_X && c2.x < COMPACT_NUMERIC_MIN_X),
|
|
46876
|
+
withdrawalCell: amounts.find((c2) => right(c2) < COMPACT_WITHDRAWAL_MAX_RIGHT),
|
|
46877
|
+
depositCell: amounts.find((c2) => right(c2) >= COMPACT_WITHDRAWAL_MAX_RIGHT && right(c2) < COMPACT_DEPOSIT_MAX_RIGHT),
|
|
46878
|
+
balanceCell: amounts.find((c2) => right(c2) >= COMPACT_DEPOSIT_MAX_RIGHT)
|
|
46879
|
+
};
|
|
46880
|
+
}
|
|
46881
|
+
return {
|
|
46882
|
+
dateCell: row.find((c2) => c2.x <= X_DATE_MAX2),
|
|
46883
|
+
narrationCells: row.filter((c2) => c2.x >= X_NARRATION_MIN && c2.x < X_NARRATION_MAX),
|
|
46884
|
+
refCell: row.find((c2) => c2.x >= X_REF_MIN && c2.x < X_VALUEDT_MIN),
|
|
46885
|
+
valueDtCell: row.find((c2) => c2.x >= X_VALUEDT_MIN && c2.x < X_WITHDRAWAL_MIN),
|
|
46886
|
+
withdrawalCell: row.find((c2) => c2.x >= X_WITHDRAWAL_MIN && c2.x < X_WITHDRAWAL_MAX),
|
|
46887
|
+
depositCell: row.find((c2) => c2.x >= X_DEPOSIT_MIN && c2.x < X_DEPOSIT_MAX),
|
|
46888
|
+
balanceCell: row.find((c2) => c2.x >= X_BALANCE_MIN)
|
|
46889
|
+
};
|
|
46890
|
+
}
|
|
46891
|
+
function parseHdfcSavingsItems(items) {
|
|
46892
|
+
const transactions = parseTransactions3(items, detectLayout(items));
|
|
46893
|
+
assertBalanceContinuity(transactions);
|
|
46894
|
+
return transactions;
|
|
46895
|
+
}
|
|
46896
|
+
function assertBalanceContinuity(txns) {
|
|
46897
|
+
let checked = 0;
|
|
46898
|
+
let bad = 0;
|
|
46899
|
+
for (let i2 = 1;i2 < txns.length; i2++) {
|
|
46900
|
+
const prev = txns[i2 - 1].closingBalance;
|
|
46901
|
+
const cur = txns[i2].closingBalance;
|
|
46902
|
+
if (prev == null || cur == null)
|
|
46903
|
+
continue;
|
|
46904
|
+
checked++;
|
|
46905
|
+
const expected = prev + (txns[i2].type === "credit" ? txns[i2].amount : -txns[i2].amount);
|
|
46906
|
+
if (Math.abs(expected - cur) > 0.02)
|
|
46907
|
+
bad++;
|
|
46908
|
+
}
|
|
46909
|
+
if (checked >= 5 && bad / checked > 0.25) {
|
|
46910
|
+
throw new Error(`HDFC savings PDF: column layout not recognised — ${bad}/${checked} rows fail the running-balance check. Refusing to return misread amounts.`);
|
|
46911
|
+
}
|
|
46912
|
+
}
|
|
46913
|
+
function parseTransactions3(items, layout) {
|
|
46914
|
+
const dataItems = items.filter((i2) => i2.y > Y_DATA_MIN2 && i2.y <= Y_DATA_MAX);
|
|
46915
|
+
const rows = buildRows3(dataItems);
|
|
46916
|
+
const transactions = [];
|
|
46917
|
+
let pending = null;
|
|
46918
|
+
for (const row of rows) {
|
|
46919
|
+
const {
|
|
46920
|
+
dateCell,
|
|
46921
|
+
narrationCells,
|
|
46922
|
+
refCell,
|
|
46923
|
+
valueDtCell,
|
|
46924
|
+
withdrawalCell,
|
|
46925
|
+
depositCell,
|
|
46926
|
+
balanceCell
|
|
46927
|
+
} = classifyRow(row, layout);
|
|
46928
|
+
const dateStr = dateCell?.str.trim() ?? "";
|
|
46929
|
+
const isDateRow = /^\d{2}\/\d{2}\/\d{2,4}$/.test(dateStr);
|
|
46930
|
+
const isColumnHeader = narrationCells.some((c2) => c2.str === "Narration" || c2.str === "Date");
|
|
46931
|
+
if (isColumnHeader)
|
|
46932
|
+
continue;
|
|
46933
|
+
if (isDateRow) {
|
|
46934
|
+
if (pending) {
|
|
46935
|
+
const txn = buildTransaction(pending);
|
|
46936
|
+
if (txn)
|
|
46937
|
+
transactions.push(txn);
|
|
46938
|
+
}
|
|
46939
|
+
pending = {
|
|
46940
|
+
dateStr,
|
|
46941
|
+
narrationParts: narrationCells.map((c2) => c2.str),
|
|
46942
|
+
refNo: refCell?.str.trim() ?? null,
|
|
46943
|
+
valueDateStr: valueDtCell?.str.trim() ?? null,
|
|
46944
|
+
withdrawalStr: withdrawalCell?.str.trim() ?? null,
|
|
46945
|
+
depositStr: depositCell?.str.trim() ?? null,
|
|
46946
|
+
balanceStr: balanceCell?.str.trim() ?? null
|
|
46217
46947
|
};
|
|
46948
|
+
} else if (narrationCells.length > 0 && !refCell && !withdrawalCell && !depositCell) {
|
|
46949
|
+
const continuationText = narrationCells.map((c2) => c2.str).join(" ");
|
|
46950
|
+
if (/STATEMENT SUMMARY/i.test(continuationText))
|
|
46951
|
+
break;
|
|
46952
|
+
if (pending) {
|
|
46953
|
+
pending.narrationParts.push(...narrationCells.map((c2) => c2.str));
|
|
46954
|
+
}
|
|
46218
46955
|
}
|
|
46219
|
-
|
|
46220
|
-
|
|
46221
|
-
|
|
46222
|
-
if (
|
|
46223
|
-
|
|
46224
|
-
|
|
46225
|
-
|
|
46226
|
-
|
|
46227
|
-
|
|
46228
|
-
|
|
46229
|
-
|
|
46230
|
-
|
|
46956
|
+
}
|
|
46957
|
+
if (pending) {
|
|
46958
|
+
const txn = buildTransaction(pending);
|
|
46959
|
+
if (txn)
|
|
46960
|
+
transactions.push(txn);
|
|
46961
|
+
}
|
|
46962
|
+
return transactions;
|
|
46963
|
+
}
|
|
46964
|
+
function buildRows3(items) {
|
|
46965
|
+
const buckets = new Map;
|
|
46966
|
+
for (const item of items) {
|
|
46967
|
+
const pagePrefix = `${item.page}:`;
|
|
46968
|
+
let bucketKey = null;
|
|
46969
|
+
for (const key of buckets.keys()) {
|
|
46970
|
+
if (!key.startsWith(pagePrefix))
|
|
46971
|
+
continue;
|
|
46972
|
+
const bucketY = parseInt(key.slice(pagePrefix.length), 10);
|
|
46973
|
+
if (Math.abs(item.y - bucketY) <= ROW_TOLERANCE3) {
|
|
46974
|
+
bucketKey = key;
|
|
46975
|
+
break;
|
|
46976
|
+
}
|
|
46231
46977
|
}
|
|
46232
|
-
|
|
46233
|
-
|
|
46234
|
-
|
|
46978
|
+
if (bucketKey === null) {
|
|
46979
|
+
bucketKey = `${item.page}:${item.y}`;
|
|
46980
|
+
buckets.set(bucketKey, [item]);
|
|
46981
|
+
} else {
|
|
46982
|
+
const existing = buckets.get(bucketKey);
|
|
46983
|
+
if (existing)
|
|
46984
|
+
existing.push(item);
|
|
46235
46985
|
}
|
|
46236
|
-
|
|
46237
|
-
|
|
46238
|
-
|
|
46986
|
+
}
|
|
46987
|
+
return [...buckets.entries()].sort((a2, b2) => {
|
|
46988
|
+
const [pa, ya] = a2[0].split(":").map(Number);
|
|
46989
|
+
const [pb, yb] = b2[0].split(":").map(Number);
|
|
46990
|
+
return pa !== pb ? pa - pb : yb - ya;
|
|
46991
|
+
}).map(([, row]) => row.sort((r1, r2) => r1.x - r2.x));
|
|
46239
46992
|
}
|
|
46240
|
-
|
|
46241
|
-
|
|
46242
|
-
|
|
46243
|
-
|
|
46244
|
-
|
|
46245
|
-
|
|
46246
|
-
|
|
46247
|
-
|
|
46248
|
-
|
|
46249
|
-
|
|
46250
|
-
|
|
46251
|
-
|
|
46252
|
-
|
|
46253
|
-
|
|
46254
|
-
|
|
46255
|
-
|
|
46256
|
-
|
|
46257
|
-
|
|
46258
|
-
|
|
46259
|
-
|
|
46260
|
-
|
|
46993
|
+
function buildTransaction(row) {
|
|
46994
|
+
const date4 = parseHdfcDate(row.dateStr);
|
|
46995
|
+
if (!date4)
|
|
46996
|
+
return null;
|
|
46997
|
+
let narration = row.narrationParts.join(" ").replace(/\s{2,}/g, " ").trim();
|
|
46998
|
+
let embeddedValueDate = null;
|
|
46999
|
+
const vd = narration.match(/\s*Value\s*Dt\s*(\d{2}\/\d{2}\/\d{2,4})/i);
|
|
47000
|
+
if (vd) {
|
|
47001
|
+
embeddedValueDate = parseHdfcDate(vd[1]);
|
|
47002
|
+
narration = narration.replace(vd[0], "").replace(/\s{2,}/g, " ").trim();
|
|
47003
|
+
}
|
|
47004
|
+
if (!narration)
|
|
47005
|
+
return null;
|
|
47006
|
+
const withdrawal = parseAmount3(row.withdrawalStr);
|
|
47007
|
+
const deposit = parseAmount3(row.depositStr);
|
|
47008
|
+
if (withdrawal === 0 && deposit === 0)
|
|
47009
|
+
return null;
|
|
47010
|
+
const amount = withdrawal > 0 ? withdrawal : deposit;
|
|
47011
|
+
const type = withdrawal > 0 ? "debit" : "credit";
|
|
47012
|
+
const valueDate = row.valueDateStr ? parseHdfcDate(row.valueDateStr) : embeddedValueDate;
|
|
47013
|
+
const closingBalance = parseAmount3(row.balanceStr) || null;
|
|
47014
|
+
const parsed = parseHdfcNarration(narration);
|
|
47015
|
+
return {
|
|
47016
|
+
date: date4,
|
|
47017
|
+
description: narration,
|
|
47018
|
+
amount,
|
|
47019
|
+
type,
|
|
47020
|
+
referenceNumber: row.refNo || null,
|
|
47021
|
+
valueDate,
|
|
47022
|
+
closingBalance,
|
|
47023
|
+
paymentMethod: parsed.paymentMethod,
|
|
47024
|
+
extractedName: parsed.name,
|
|
47025
|
+
upiId: parsed.upiId,
|
|
47026
|
+
sourceCategory: null
|
|
47027
|
+
};
|
|
47028
|
+
}
|
|
47029
|
+
function parseHdfcDate(raw) {
|
|
47030
|
+
const match = raw.trim().match(/^(\d{2})\/(\d{2})\/(\d{2,4})$/);
|
|
47031
|
+
if (!match)
|
|
47032
|
+
return null;
|
|
47033
|
+
const day = match[1];
|
|
47034
|
+
const month = match[2];
|
|
47035
|
+
let year = match[3];
|
|
47036
|
+
if (year.length === 2) {
|
|
47037
|
+
const num = parseInt(year, 10);
|
|
47038
|
+
year = num > 50 ? `19${year}` : `20${year}`;
|
|
47039
|
+
}
|
|
47040
|
+
return `${year}-${month}-${day}`;
|
|
47041
|
+
}
|
|
47042
|
+
function parseAmount3(raw) {
|
|
47043
|
+
if (!raw)
|
|
47044
|
+
return 0;
|
|
47045
|
+
const cleaned = raw.replace(/,/g, "").trim();
|
|
47046
|
+
const num = parseFloat(cleaned);
|
|
47047
|
+
return Number.isNaN(num) ? 0 : Math.abs(num);
|
|
47048
|
+
}
|
|
47049
|
+
// ../parsers/src/sbi/savings-pdf.ts
|
|
47050
|
+
var _pdfjs4 = null;
|
|
47051
|
+
async function getPdfjs4() {
|
|
47052
|
+
if (!_pdfjs4) {
|
|
47053
|
+
_pdfjs4 = await import("pdfjs-dist/legacy/build/pdf.mjs");
|
|
47054
|
+
_pdfjs4.GlobalWorkerOptions.workerSrc = import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs");
|
|
47055
|
+
}
|
|
47056
|
+
return _pdfjs4;
|
|
47057
|
+
}
|
|
47058
|
+
var X_DATE_MAX3 = 60;
|
|
47059
|
+
var X_VALUEDT_MIN2 = 61;
|
|
47060
|
+
var X_VALUEDT_MAX = 120;
|
|
47061
|
+
var X_NARRATION_MIN2 = 120;
|
|
47062
|
+
var X_NARRATION_MAX2 = 285;
|
|
47063
|
+
var X_DEBIT_MIN = 310;
|
|
47064
|
+
var X_DEBIT_MAX = 395;
|
|
47065
|
+
var X_CREDIT_MIN = 395;
|
|
47066
|
+
var X_CREDIT_MAX = 465;
|
|
47067
|
+
var X_BALANCE_MIN2 = 465;
|
|
47068
|
+
var Y_FOOTER_MIN = 80;
|
|
47069
|
+
var ROW_TOLERANCE4 = 4;
|
|
47070
|
+
var NARRATION_ABOVE_TOLERANCE = 12;
|
|
47071
|
+
async function parseSbiSavingsPdf(data, password) {
|
|
47072
|
+
const pdfjs = await getPdfjs4();
|
|
47073
|
+
const loadOptions = {
|
|
47074
|
+
data: new Uint8Array(data),
|
|
47075
|
+
useWorkerFetch: false,
|
|
47076
|
+
isEvalSupported: false,
|
|
47077
|
+
useSystemFonts: true
|
|
47078
|
+
};
|
|
47079
|
+
if (password)
|
|
47080
|
+
loadOptions.password = password;
|
|
47081
|
+
const pdf = await pdfjs.getDocument(loadOptions).promise;
|
|
47082
|
+
const items = await extractTextItems4(pdf);
|
|
47083
|
+
const metadata = extractMetadata4(items);
|
|
47084
|
+
const transactions = parseTransactions4(items);
|
|
47085
|
+
return {
|
|
47086
|
+
bank: "sbi",
|
|
47087
|
+
accountType: metadata.accountType,
|
|
47088
|
+
accountNumber: metadata.accountLast4,
|
|
47089
|
+
periodStart: metadata.periodStart,
|
|
47090
|
+
periodEnd: metadata.periodEnd,
|
|
47091
|
+
transactions
|
|
47092
|
+
};
|
|
47093
|
+
}
|
|
47094
|
+
async function extractTextItems4(pdf) {
|
|
47095
|
+
const result = [];
|
|
47096
|
+
for (let p2 = 1;p2 <= pdf.numPages; p2++) {
|
|
47097
|
+
const page = await pdf.getPage(p2);
|
|
47098
|
+
const content = await page.getTextContent();
|
|
47099
|
+
for (const item of content.items) {
|
|
47100
|
+
const ti = item;
|
|
47101
|
+
if (typeof ti.str !== "string" || !ti.str.trim())
|
|
47102
|
+
continue;
|
|
47103
|
+
result.push({
|
|
47104
|
+
page: p2,
|
|
47105
|
+
x: Math.round(ti.transform[4]),
|
|
47106
|
+
y: Math.round(ti.transform[5]),
|
|
47107
|
+
str: ti.str
|
|
47108
|
+
});
|
|
46261
47109
|
}
|
|
46262
|
-
}
|
|
46263
|
-
|
|
46264
|
-
|
|
46265
|
-
|
|
47110
|
+
}
|
|
47111
|
+
return result;
|
|
47112
|
+
}
|
|
47113
|
+
function extractMetadata4(items) {
|
|
47114
|
+
const page1Items = items.filter((i2) => i2.page === 1);
|
|
47115
|
+
const rowStrings = groupIntoRowStrings3(page1Items);
|
|
47116
|
+
const fullText = rowStrings.join(" ");
|
|
47117
|
+
let accountType = "savings";
|
|
47118
|
+
let accountLast4 = null;
|
|
47119
|
+
let periodStart = null;
|
|
47120
|
+
let periodEnd = null;
|
|
47121
|
+
const accountMatch = fullText.match(/Account Number\s*:\s*(\d{8,18})/);
|
|
47122
|
+
if (accountMatch) {
|
|
47123
|
+
accountLast4 = accountMatch[1].slice(-4);
|
|
47124
|
+
}
|
|
47125
|
+
if (/current.*account/i.test(fullText)) {
|
|
47126
|
+
accountType = "current";
|
|
47127
|
+
}
|
|
47128
|
+
const periodMatch = fullText.match(/Statement From\s*:\s*(\d{2}-\d{2}-\d{4})\s+to\s+(\d{2}-\d{2}-\d{4})/i);
|
|
47129
|
+
if (periodMatch) {
|
|
47130
|
+
periodStart = parseSbiDate(periodMatch[1]);
|
|
47131
|
+
periodEnd = parseSbiDate(periodMatch[2]);
|
|
47132
|
+
}
|
|
47133
|
+
return { accountType, accountLast4, periodStart, periodEnd };
|
|
47134
|
+
}
|
|
47135
|
+
function groupIntoRowStrings3(items) {
|
|
47136
|
+
const buckets = new Map;
|
|
47137
|
+
for (const item of items) {
|
|
47138
|
+
const pagePrefix = `${item.page}:`;
|
|
47139
|
+
let bucketKey = null;
|
|
47140
|
+
for (const key of buckets.keys()) {
|
|
47141
|
+
if (!key.startsWith(pagePrefix))
|
|
47142
|
+
continue;
|
|
47143
|
+
if (Math.abs(item.y - parseInt(key.slice(pagePrefix.length), 10)) <= ROW_TOLERANCE4) {
|
|
47144
|
+
bucketKey = key;
|
|
47145
|
+
break;
|
|
47146
|
+
}
|
|
47147
|
+
}
|
|
47148
|
+
if (bucketKey === null) {
|
|
47149
|
+
bucketKey = `${item.page}:${item.y}`;
|
|
47150
|
+
buckets.set(bucketKey, [item]);
|
|
46266
47151
|
} else {
|
|
46267
|
-
const
|
|
46268
|
-
|
|
46269
|
-
|
|
46270
|
-
const liabilities = parseFloat(body.liabilities ?? "0");
|
|
46271
|
-
const totalNetWorth = (liquidCash + investments + emergencyFund - liabilities).toFixed(2);
|
|
46272
|
-
data = await client.post("/api/net-worth", {
|
|
46273
|
-
...body,
|
|
46274
|
-
emergencyFund: body.emergencyFund ?? "0",
|
|
46275
|
-
totalNetWorth
|
|
46276
|
-
});
|
|
47152
|
+
const existing = buckets.get(bucketKey);
|
|
47153
|
+
if (existing)
|
|
47154
|
+
existing.push(item);
|
|
46277
47155
|
}
|
|
46278
|
-
|
|
46279
|
-
|
|
47156
|
+
}
|
|
47157
|
+
return [...buckets.values()].map((row) => row.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" "));
|
|
46280
47158
|
}
|
|
46281
|
-
|
|
46282
|
-
|
|
46283
|
-
|
|
46284
|
-
|
|
46285
|
-
|
|
46286
|
-
|
|
46287
|
-
|
|
46288
|
-
|
|
46289
|
-
upiHandles: exports_external.array(exports_external.string()).optional().describe("UPI ids the person uses"),
|
|
46290
|
-
phoneNumbers: exports_external.array(exports_external.string()).optional(),
|
|
46291
|
-
relationship: exports_external.string().optional().describe('e.g. "sister", "friend", "landlord"'),
|
|
46292
|
-
notes: exports_external.string().optional()
|
|
47159
|
+
function parseTransactions4(items) {
|
|
47160
|
+
const dataItems = items.filter((i2) => i2.y > Y_FOOTER_MIN);
|
|
47161
|
+
const rows = buildRows4(dataItems);
|
|
47162
|
+
const dateRows = [];
|
|
47163
|
+
for (const row of rows) {
|
|
47164
|
+
const dateCell = row.find((c2) => c2.x <= X_DATE_MAX3 && /^\d{2}\/\d{2}\/\d{4}$/.test(c2.str.trim()));
|
|
47165
|
+
if (dateCell) {
|
|
47166
|
+
dateRows.push({ page: dateCell.page, y: dateCell.y, row });
|
|
46293
47167
|
}
|
|
46294
|
-
}
|
|
46295
|
-
|
|
46296
|
-
|
|
46297
|
-
|
|
46298
|
-
|
|
46299
|
-
|
|
46300
|
-
|
|
46301
|
-
|
|
46302
|
-
|
|
46303
|
-
|
|
46304
|
-
|
|
46305
|
-
|
|
46306
|
-
|
|
46307
|
-
|
|
46308
|
-
|
|
46309
|
-
|
|
46310
|
-
|
|
46311
|
-
|
|
46312
|
-
|
|
46313
|
-
|
|
46314
|
-
|
|
46315
|
-
|
|
46316
|
-
|
|
46317
|
-
|
|
46318
|
-
|
|
46319
|
-
|
|
46320
|
-
|
|
47168
|
+
}
|
|
47169
|
+
const buckets = new Map;
|
|
47170
|
+
for (const { page, y: y2, row } of dateRows) {
|
|
47171
|
+
const key = `${page}:${y2}`;
|
|
47172
|
+
const dateCell = row.find((c2) => c2.x <= X_DATE_MAX3);
|
|
47173
|
+
if (!dateCell)
|
|
47174
|
+
continue;
|
|
47175
|
+
const valueDtCell = row.find((c2) => c2.x >= X_VALUEDT_MIN2 && c2.x < X_VALUEDT_MAX);
|
|
47176
|
+
const debitCell = row.find((c2) => c2.x >= X_DEBIT_MIN && c2.x < X_DEBIT_MAX && c2.str.trim() !== "-");
|
|
47177
|
+
const creditCell = row.find((c2) => c2.x >= X_CREDIT_MIN && c2.x < X_CREDIT_MAX && c2.str.trim() !== "-");
|
|
47178
|
+
const balanceCell = row.find((c2) => c2.x >= X_BALANCE_MIN2);
|
|
47179
|
+
buckets.set(key, {
|
|
47180
|
+
page,
|
|
47181
|
+
dateY: y2,
|
|
47182
|
+
dateStr: dateCell.str.trim(),
|
|
47183
|
+
valueDateStr: valueDtCell?.str.trim() ?? null,
|
|
47184
|
+
debitStr: debitCell?.str.trim() ?? null,
|
|
47185
|
+
creditStr: creditCell?.str.trim() ?? null,
|
|
47186
|
+
balanceStr: balanceCell?.str.trim() ?? null,
|
|
47187
|
+
narrationParts: []
|
|
47188
|
+
});
|
|
47189
|
+
}
|
|
47190
|
+
const narrationItems = dataItems.filter((i2) => i2.x >= X_NARRATION_MIN2 && i2.x < X_NARRATION_MAX2);
|
|
47191
|
+
for (const item of narrationItems) {
|
|
47192
|
+
const yMin = item.y - NARRATION_ABOVE_TOLERANCE;
|
|
47193
|
+
let bestKey = null;
|
|
47194
|
+
let bestY = Number.MAX_SAFE_INTEGER;
|
|
47195
|
+
for (const { page, y: y2 } of dateRows) {
|
|
47196
|
+
if (page !== item.page)
|
|
47197
|
+
continue;
|
|
47198
|
+
if (y2 >= yMin && y2 < bestY) {
|
|
47199
|
+
bestY = y2;
|
|
47200
|
+
bestKey = `${page}:${y2}`;
|
|
46321
47201
|
}
|
|
46322
|
-
if (!updates.name)
|
|
46323
|
-
throw new Error("name is required when creating a person");
|
|
46324
|
-
const fields = {
|
|
46325
|
-
name: updates.name,
|
|
46326
|
-
upiHandles: updates.upiHandles ?? [],
|
|
46327
|
-
phoneNumbers: updates.phoneNumbers ?? [],
|
|
46328
|
-
relationship: updates.relationship ?? null,
|
|
46329
|
-
notes: updates.notes ?? null
|
|
46330
|
-
};
|
|
46331
|
-
const payload = await encryptPersonCipher(fields, crypto3.key);
|
|
46332
|
-
const data2 = await client.post("/api/persons", payload);
|
|
46333
|
-
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
46334
47202
|
}
|
|
46335
|
-
|
|
46336
|
-
|
|
46337
|
-
|
|
46338
|
-
|
|
46339
|
-
description: "List all persons in the current household. Pass `id` to get a single person by id instead of listing all.",
|
|
46340
|
-
inputSchema: {
|
|
46341
|
-
id: exports_external.string().optional().describe("Person UUID to fetch a single person; omit to list all")
|
|
47203
|
+
if (bestKey) {
|
|
47204
|
+
const bucket = buckets.get(bestKey);
|
|
47205
|
+
if (bucket)
|
|
47206
|
+
bucket.narrationParts.push(item.str);
|
|
46342
47207
|
}
|
|
46343
|
-
}
|
|
46344
|
-
|
|
46345
|
-
|
|
46346
|
-
|
|
46347
|
-
|
|
46348
|
-
|
|
47208
|
+
}
|
|
47209
|
+
const sortedBuckets = [...buckets.values()].sort((a2, b2) => a2.page !== b2.page ? a2.page - b2.page : b2.dateY - a2.dateY);
|
|
47210
|
+
const transactions = [];
|
|
47211
|
+
for (const bucket of sortedBuckets) {
|
|
47212
|
+
const txn = buildTransaction2(bucket);
|
|
47213
|
+
if (txn)
|
|
47214
|
+
transactions.push(txn);
|
|
47215
|
+
}
|
|
47216
|
+
return transactions;
|
|
47217
|
+
}
|
|
47218
|
+
function buildRows4(items) {
|
|
47219
|
+
const buckets = new Map;
|
|
47220
|
+
for (const item of items) {
|
|
47221
|
+
const pagePrefix = `${item.page}:`;
|
|
47222
|
+
let bucketKey = null;
|
|
47223
|
+
for (const key of buckets.keys()) {
|
|
47224
|
+
if (!key.startsWith(pagePrefix))
|
|
47225
|
+
continue;
|
|
47226
|
+
const bucketY = parseInt(key.slice(pagePrefix.length), 10);
|
|
47227
|
+
if (Math.abs(item.y - bucketY) <= ROW_TOLERANCE4) {
|
|
47228
|
+
bucketKey = key;
|
|
47229
|
+
break;
|
|
46349
47230
|
}
|
|
46350
|
-
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
46351
47231
|
}
|
|
46352
|
-
|
|
46353
|
-
|
|
46354
|
-
|
|
46355
|
-
|
|
46356
|
-
|
|
46357
|
-
|
|
46358
|
-
|
|
46359
|
-
Object.assign(person, fields, { cipher: null });
|
|
46360
|
-
} catch {}
|
|
46361
|
-
}
|
|
46362
|
-
}
|
|
47232
|
+
if (bucketKey === null) {
|
|
47233
|
+
bucketKey = `${item.page}:${item.y}`;
|
|
47234
|
+
buckets.set(bucketKey, [item]);
|
|
47235
|
+
} else {
|
|
47236
|
+
const existing = buckets.get(bucketKey);
|
|
47237
|
+
if (existing)
|
|
47238
|
+
existing.push(item);
|
|
46363
47239
|
}
|
|
46364
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46365
|
-
});
|
|
46366
|
-
server.registerTool("delete_person", {
|
|
46367
|
-
description: "Delete a person. Returns 409 if any transactions still reference this person.",
|
|
46368
|
-
inputSchema: { id: exports_external.string().describe("Person UUID") }
|
|
46369
|
-
}, async ({ id }) => {
|
|
46370
|
-
const data = await client.delete(`/api/persons/${id}`);
|
|
46371
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46372
|
-
});
|
|
46373
|
-
}
|
|
46374
|
-
|
|
46375
|
-
// src/tools/profile.ts
|
|
46376
|
-
function registerProfileTools(server, client) {
|
|
46377
|
-
server.registerTool("get_profile", {
|
|
46378
|
-
description: "Get the current user's profile (name, email, owner).",
|
|
46379
|
-
inputSchema: {}
|
|
46380
|
-
}, async () => {
|
|
46381
|
-
const data = await client.get("/api/profile");
|
|
46382
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46383
|
-
});
|
|
46384
|
-
server.registerTool("update_profile", {
|
|
46385
|
-
description: "Update your display name.",
|
|
46386
|
-
inputSchema: {
|
|
46387
|
-
name: exports_external.string().min(1).describe("Display name")
|
|
46388
|
-
}
|
|
46389
|
-
}, async (body) => {
|
|
46390
|
-
const data = await client.patch("/api/profile", body);
|
|
46391
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46392
|
-
});
|
|
46393
|
-
}
|
|
46394
|
-
|
|
46395
|
-
// src/tools/push.ts
|
|
46396
|
-
function registerPushTools(server, client) {
|
|
46397
|
-
server.registerTool("list_push_subscriptions", {
|
|
46398
|
-
description: "List the household's registered web-push subscriptions (browser/device endpoints that receive alert notifications).",
|
|
46399
|
-
inputSchema: {}
|
|
46400
|
-
}, async () => {
|
|
46401
|
-
const data = await client.get("/api/push/subscriptions");
|
|
46402
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46403
|
-
});
|
|
46404
|
-
server.registerTool("send_test_push", {
|
|
46405
|
-
description: "Send a test web-push notification to all of the household's subscriptions. Use to verify push delivery is working.",
|
|
46406
|
-
inputSchema: {}
|
|
46407
|
-
}, async () => {
|
|
46408
|
-
const data = await client.post("/api/push/test");
|
|
46409
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46410
|
-
});
|
|
46411
|
-
}
|
|
46412
|
-
|
|
46413
|
-
// src/tools/recurring.ts
|
|
46414
|
-
function registerRecurringTools(server, client) {
|
|
46415
|
-
server.registerTool("list_recurring", {
|
|
46416
|
-
description: "List all recurring transactions (subscriptions, rent, salaries).",
|
|
46417
|
-
inputSchema: {}
|
|
46418
|
-
}, async () => {
|
|
46419
|
-
const data = await client.get("/api/recurring");
|
|
46420
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46421
|
-
});
|
|
46422
|
-
server.registerTool("add_recurring", {
|
|
46423
|
-
description: "Add a recurring transaction to track subscriptions or regular income.",
|
|
46424
|
-
inputSchema: {
|
|
46425
|
-
name: exports_external.string(),
|
|
46426
|
-
amount: exports_external.string(),
|
|
46427
|
-
type: exports_external.enum(["debit", "credit"]),
|
|
46428
|
-
frequency: exports_external.enum(["daily", "weekly", "monthly", "yearly"]),
|
|
46429
|
-
dayOfMonth: exports_external.number().optional().describe("For monthly — day of month (1-31)"),
|
|
46430
|
-
categorySlug: exports_external.string().optional(),
|
|
46431
|
-
owner: exports_external.string(),
|
|
46432
|
-
startDate: exports_external.string().optional().describe("YYYY-MM-DD"),
|
|
46433
|
-
notes: exports_external.string().optional()
|
|
46434
|
-
}
|
|
46435
|
-
}, async (body) => {
|
|
46436
|
-
const data = await client.post("/api/recurring", body);
|
|
46437
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46438
|
-
});
|
|
46439
|
-
}
|
|
46440
|
-
|
|
46441
|
-
// src/tools/reports.ts
|
|
46442
|
-
function registerReportTools(server, client) {
|
|
46443
|
-
server.registerTool("generate_monthly_report", {
|
|
46444
|
-
description: "Generate a detailed monthly financial report. Computes income, expenses, savings rate, top categories, and insights. Persists to DB.",
|
|
46445
|
-
inputSchema: {
|
|
46446
|
-
month: exports_external.number().min(1).max(12),
|
|
46447
|
-
year: exports_external.number().min(2020).max(2100)
|
|
46448
|
-
}
|
|
46449
|
-
}, async (body) => {
|
|
46450
|
-
const data = await client.post("/api/reports", body);
|
|
46451
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46452
|
-
});
|
|
46453
|
-
server.registerTool("get_reports", {
|
|
46454
|
-
description: "Monthly financial reports for the last N months — income, expenses, savings rate, net worth.",
|
|
46455
|
-
inputSchema: {
|
|
46456
|
-
months: exports_external.number().optional().describe("Number of months (default: 6)")
|
|
46457
|
-
}
|
|
46458
|
-
}, async (params) => {
|
|
46459
|
-
const data = await client.get("/api/reports", params.months ? { months: params.months } : undefined);
|
|
46460
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46461
|
-
});
|
|
46462
|
-
}
|
|
46463
|
-
|
|
46464
|
-
// src/tools/transactions.ts
|
|
46465
|
-
import { readFile } from "node:fs/promises";
|
|
46466
|
-
// ../parsers/src/classify.ts
|
|
46467
|
-
var CORPORATE_TOKENS = [
|
|
46468
|
-
"PVT",
|
|
46469
|
-
"PRIVATE",
|
|
46470
|
-
"LIMITED",
|
|
46471
|
-
"LTD",
|
|
46472
|
-
"LLP",
|
|
46473
|
-
"INC",
|
|
46474
|
-
"CORP",
|
|
46475
|
-
"ENTERPRISES",
|
|
46476
|
-
"TECHNOLOGIES",
|
|
46477
|
-
"SOLUTIONS",
|
|
46478
|
-
"SERVICES"
|
|
46479
|
-
];
|
|
46480
|
-
var CORPORATE_REGEX = new RegExp(`\\b(${CORPORATE_TOKENS.join("|")})\\b`);
|
|
46481
|
-
function classifyEntity({ paymentMethod, extractedName }) {
|
|
46482
|
-
if (paymentMethod === "atm")
|
|
46483
|
-
return "merchant";
|
|
46484
|
-
if (paymentMethod === "pos")
|
|
46485
|
-
return "merchant";
|
|
46486
|
-
const upper = (extractedName ?? "").toUpperCase();
|
|
46487
|
-
if (upper && CORPORATE_REGEX.test(upper))
|
|
46488
|
-
return "merchant";
|
|
46489
|
-
if (paymentMethod === "imps")
|
|
46490
|
-
return "person";
|
|
46491
|
-
if (paymentMethod === "upi")
|
|
46492
|
-
return "person";
|
|
46493
|
-
if (paymentMethod === "neft" || paymentMethod === "rtgs")
|
|
46494
|
-
return "merchant";
|
|
46495
|
-
if (paymentMethod === "ach")
|
|
46496
|
-
return "merchant";
|
|
46497
|
-
return "unknown";
|
|
46498
|
-
}
|
|
46499
|
-
// ../parsers/src/hdfc/narration.ts
|
|
46500
|
-
function parseHdfcNarration(narration) {
|
|
46501
|
-
const trimmed = narration.trim();
|
|
46502
|
-
if (trimmed.startsWith("UPI-")) {
|
|
46503
|
-
return parseUpi(trimmed);
|
|
46504
|
-
}
|
|
46505
|
-
if (trimmed.startsWith("NEFT CR-") || trimmed.startsWith("NEFT DR-")) {
|
|
46506
|
-
return parseNeft(trimmed);
|
|
46507
47240
|
}
|
|
46508
|
-
|
|
46509
|
-
|
|
46510
|
-
|
|
46511
|
-
|
|
46512
|
-
|
|
46513
|
-
}
|
|
46514
|
-
if (trimmed.startsWith("IMPS-") || trimmed.startsWith("IMPS/")) {
|
|
46515
|
-
return parseImps(trimmed);
|
|
46516
|
-
}
|
|
46517
|
-
if (trimmed.startsWith("ATW-") || trimmed.startsWith("ATM-") || trimmed.startsWith("ATM/")) {
|
|
46518
|
-
return {
|
|
46519
|
-
paymentMethod: "atm",
|
|
46520
|
-
name: "ATM Withdrawal",
|
|
46521
|
-
upiId: null,
|
|
46522
|
-
referenceNumber: null,
|
|
46523
|
-
description: trimmed
|
|
46524
|
-
};
|
|
46525
|
-
}
|
|
46526
|
-
if (trimmed.startsWith("POS ")) {
|
|
46527
|
-
return parsePos(trimmed);
|
|
46528
|
-
}
|
|
46529
|
-
return parseFallback(trimmed);
|
|
47241
|
+
return [...buckets.entries()].sort((a2, b2) => {
|
|
47242
|
+
const [pa, ya] = a2[0].split(":").map(Number);
|
|
47243
|
+
const [pb, yb] = b2[0].split(":").map(Number);
|
|
47244
|
+
return pa !== pb ? pa - pb : yb - ya;
|
|
47245
|
+
}).map(([, row]) => row.sort((r1, r2) => r1.x - r2.x));
|
|
46530
47246
|
}
|
|
46531
|
-
function
|
|
46532
|
-
const
|
|
46533
|
-
|
|
46534
|
-
|
|
46535
|
-
|
|
46536
|
-
|
|
46537
|
-
|
|
46538
|
-
|
|
46539
|
-
|
|
46540
|
-
|
|
46541
|
-
|
|
46542
|
-
|
|
46543
|
-
|
|
46544
|
-
const
|
|
46545
|
-
const
|
|
46546
|
-
const
|
|
47247
|
+
function buildTransaction2(bucket) {
|
|
47248
|
+
const date4 = parseSbiDate(bucket.dateStr);
|
|
47249
|
+
if (!date4)
|
|
47250
|
+
return null;
|
|
47251
|
+
const terminalIdx = bucket.narrationParts.findIndex((p2) => /^\d+\s+AT\s+\d+/.test(p2.trim()));
|
|
47252
|
+
const parts = terminalIdx >= 0 ? bucket.narrationParts.slice(0, terminalIdx + 1) : bucket.narrationParts;
|
|
47253
|
+
const narration = parts.join(" ").replace(/\s{2,}/g, " ").trim();
|
|
47254
|
+
if (!narration)
|
|
47255
|
+
return null;
|
|
47256
|
+
const debit = parseAmount4(bucket.debitStr);
|
|
47257
|
+
const credit = parseAmount4(bucket.creditStr);
|
|
47258
|
+
if (debit === 0 && credit === 0)
|
|
47259
|
+
return null;
|
|
47260
|
+
const amount = debit > 0 ? debit : credit;
|
|
47261
|
+
const type = debit > 0 ? "debit" : "credit";
|
|
47262
|
+
const valueDate = bucket.valueDateStr ? parseSbiDate(bucket.valueDateStr) : null;
|
|
47263
|
+
const closingBalance = parseAmount4(bucket.balanceStr) || null;
|
|
47264
|
+
const parsed = parseSbiNarration(narration);
|
|
47265
|
+
const upiRefMatch = narration.match(/UPI\/(?:DR|CR)\/(\d+)\//i);
|
|
47266
|
+
const referenceNumber = upiRefMatch ? upiRefMatch[1] : null;
|
|
46547
47267
|
return {
|
|
46548
|
-
|
|
46549
|
-
|
|
46550
|
-
|
|
47268
|
+
date: date4,
|
|
47269
|
+
description: narration,
|
|
47270
|
+
amount,
|
|
47271
|
+
type,
|
|
46551
47272
|
referenceNumber,
|
|
46552
|
-
|
|
47273
|
+
valueDate,
|
|
47274
|
+
closingBalance,
|
|
47275
|
+
paymentMethod: parsed.paymentMethod,
|
|
47276
|
+
extractedName: parsed.name,
|
|
47277
|
+
upiId: parsed.upiId,
|
|
47278
|
+
sourceCategory: null
|
|
46553
47279
|
};
|
|
46554
47280
|
}
|
|
46555
|
-
function
|
|
46556
|
-
|
|
46557
|
-
|
|
46558
|
-
const
|
|
46559
|
-
if (
|
|
46560
|
-
return
|
|
46561
|
-
|
|
46562
|
-
name: rest,
|
|
46563
|
-
upiId: null,
|
|
46564
|
-
referenceNumber: null,
|
|
46565
|
-
description: null
|
|
46566
|
-
};
|
|
46567
|
-
}
|
|
46568
|
-
const companyName = parts[1]?.trim() || parts[0]?.trim() || rest;
|
|
46569
|
-
const referenceNumber = parts[parts.length - 1]?.trim() || null;
|
|
46570
|
-
return {
|
|
46571
|
-
paymentMethod: "neft",
|
|
46572
|
-
name: cleanName(companyName),
|
|
46573
|
-
upiId: null,
|
|
46574
|
-
referenceNumber,
|
|
46575
|
-
description: null
|
|
46576
|
-
};
|
|
47281
|
+
function parseSbiDate(raw) {
|
|
47282
|
+
if (!raw)
|
|
47283
|
+
return null;
|
|
47284
|
+
const match = raw.trim().match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/);
|
|
47285
|
+
if (!match)
|
|
47286
|
+
return null;
|
|
47287
|
+
return `${match[3]}-${match[2]}-${match[1]}`;
|
|
46577
47288
|
}
|
|
46578
|
-
function
|
|
46579
|
-
|
|
46580
|
-
|
|
46581
|
-
const
|
|
46582
|
-
const
|
|
46583
|
-
return
|
|
46584
|
-
paymentMethod: "ach",
|
|
46585
|
-
name: cleanName(name),
|
|
46586
|
-
upiId: null,
|
|
46587
|
-
referenceNumber,
|
|
46588
|
-
description: null
|
|
46589
|
-
};
|
|
47289
|
+
function parseAmount4(raw) {
|
|
47290
|
+
if (!raw)
|
|
47291
|
+
return 0;
|
|
47292
|
+
const cleaned = raw.replace(/,/g, "").trim();
|
|
47293
|
+
const num = parseFloat(cleaned);
|
|
47294
|
+
return Number.isNaN(num) ? 0 : Math.abs(num);
|
|
46590
47295
|
}
|
|
46591
|
-
|
|
46592
|
-
|
|
46593
|
-
const spaceDashParts = rest.split(" - ");
|
|
46594
|
-
if (spaceDashParts.length >= 2) {
|
|
46595
|
-
const namePart = spaceDashParts.find((p2, i2) => i2 > 0 && p2.trim().length > 0);
|
|
46596
|
-
return {
|
|
46597
|
-
paymentMethod: "fund_transfer",
|
|
46598
|
-
name: cleanName(namePart?.trim() || rest),
|
|
46599
|
-
upiId: null,
|
|
46600
|
-
referenceNumber: spaceDashParts[0]?.split("-")[0]?.trim() || null,
|
|
46601
|
-
description: null
|
|
46602
|
-
};
|
|
46603
|
-
}
|
|
47296
|
+
// src/lib/txn-rows.ts
|
|
47297
|
+
function toTxnRow(r2) {
|
|
46604
47298
|
return {
|
|
46605
|
-
|
|
46606
|
-
|
|
46607
|
-
|
|
46608
|
-
|
|
46609
|
-
description:
|
|
47299
|
+
id: String(r2.id),
|
|
47300
|
+
date: String(r2.date),
|
|
47301
|
+
amount: Number(r2.amount),
|
|
47302
|
+
type: r2.type === "credit" ? "credit" : "debit",
|
|
47303
|
+
description: String(r2.description ?? ""),
|
|
47304
|
+
merchantId: r2.merchantId ?? null,
|
|
47305
|
+
personId: r2.personId ?? null,
|
|
47306
|
+
categoryId: r2.categoryId ?? null,
|
|
47307
|
+
isTransfer: !!r2.isTransfer,
|
|
47308
|
+
isIgnored: !!r2.isIgnored,
|
|
47309
|
+
isDuplicate: !!r2.isDuplicate
|
|
46610
47310
|
};
|
|
46611
47311
|
}
|
|
46612
|
-
function
|
|
46613
|
-
|
|
46614
|
-
const parts = rest.split("-");
|
|
46615
|
-
const referenceNumber = parts[0]?.trim() || null;
|
|
46616
|
-
const name = parts.length > 1 ? parts[1]?.trim() : rest;
|
|
46617
|
-
return {
|
|
46618
|
-
paymentMethod: "imps",
|
|
46619
|
-
name: cleanName(name || rest),
|
|
46620
|
-
upiId: null,
|
|
46621
|
-
referenceNumber,
|
|
46622
|
-
description: null
|
|
46623
|
-
};
|
|
47312
|
+
async function fetchAllRows(client, crypto3, params = {}, maxPages = 100) {
|
|
47313
|
+
return (await fetchAllRowsPaged(client, crypto3, params, maxPages)).rows;
|
|
46624
47314
|
}
|
|
46625
|
-
function
|
|
46626
|
-
const
|
|
46627
|
-
|
|
47315
|
+
async function fetchAllRowsPaged(client, crypto3, params = {}, maxPages = 100) {
|
|
47316
|
+
const out = [];
|
|
47317
|
+
let cursor;
|
|
47318
|
+
let truncated = false;
|
|
47319
|
+
for (let page = 0;page < maxPages; page++) {
|
|
47320
|
+
const q2 = { ...params, limit: 200, view: "match" };
|
|
47321
|
+
if (cursor)
|
|
47322
|
+
q2.cursor = cursor;
|
|
47323
|
+
const res = await client.get("/api/transactions", q2);
|
|
47324
|
+
const batch = res.data ?? [];
|
|
47325
|
+
if (crypto3)
|
|
47326
|
+
await decryptTransactionFields(batch, crypto3.key);
|
|
47327
|
+
out.push(...batch.map(toTxnRow));
|
|
47328
|
+
if (!res.hasMore || !res.nextCursor || batch.length === 0 || res.nextCursor === cursor)
|
|
47329
|
+
break;
|
|
47330
|
+
cursor = res.nextCursor;
|
|
47331
|
+
if (page === maxPages - 1)
|
|
47332
|
+
truncated = true;
|
|
47333
|
+
}
|
|
47334
|
+
return { rows: out, truncated };
|
|
47335
|
+
}
|
|
47336
|
+
var MAX_DESC = 70;
|
|
47337
|
+
function toLeanRow(r2, personNames) {
|
|
47338
|
+
const merchant = r2.merchant;
|
|
47339
|
+
const category = r2.category;
|
|
47340
|
+
const merchantName = r2.merchantName ?? merchant?.cleanName;
|
|
47341
|
+
const categorySlug = typeof category === "string" ? category : category?.slug;
|
|
47342
|
+
const personId = r2.personId;
|
|
47343
|
+
const person = personId ? personNames.get(personId) ?? null : null;
|
|
47344
|
+
let flags = r2.flags;
|
|
47345
|
+
if (!flags) {
|
|
47346
|
+
flags = [];
|
|
47347
|
+
if (r2.isTransfer)
|
|
47348
|
+
flags.push("transfer");
|
|
47349
|
+
if (r2.isIgnored)
|
|
47350
|
+
flags.push("ignored");
|
|
47351
|
+
if (r2.isDuplicate)
|
|
47352
|
+
flags.push("duplicate");
|
|
47353
|
+
}
|
|
47354
|
+
const desc = String(r2.description ?? "");
|
|
46628
47355
|
return {
|
|
46629
|
-
|
|
46630
|
-
|
|
46631
|
-
|
|
46632
|
-
|
|
46633
|
-
|
|
47356
|
+
id: r2.id,
|
|
47357
|
+
date: r2.date,
|
|
47358
|
+
amt: Number(r2.amount),
|
|
47359
|
+
t: r2.type === "credit" ? "cr" : "dr",
|
|
47360
|
+
desc: desc.length > MAX_DESC ? `${desc.slice(0, MAX_DESC)}…` : desc,
|
|
47361
|
+
...categorySlug ? { cat: categorySlug } : {},
|
|
47362
|
+
...merchantName ? { merchant: merchantName } : {},
|
|
47363
|
+
...person ? { person } : {},
|
|
47364
|
+
...flags.length ? { flags } : {}
|
|
46634
47365
|
};
|
|
46635
47366
|
}
|
|
46636
|
-
|
|
47367
|
+
|
|
47368
|
+
// src/lib/person-backfill.ts
|
|
47369
|
+
function normalizePersonName(name) {
|
|
47370
|
+
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
|
47371
|
+
}
|
|
47372
|
+
async function loadDecryptedPersons(client, crypto3) {
|
|
47373
|
+
const res = await client.get("/api/persons");
|
|
47374
|
+
const persons = [];
|
|
47375
|
+
for (const p2 of res.data ?? []) {
|
|
47376
|
+
let name = p2.name ?? "";
|
|
47377
|
+
if (crypto3 && p2.cipher) {
|
|
47378
|
+
try {
|
|
47379
|
+
name = (await decryptPersonCipher(p2.cipher, crypto3.key)).name;
|
|
47380
|
+
} catch {}
|
|
47381
|
+
}
|
|
47382
|
+
if (name)
|
|
47383
|
+
persons.push({ id: p2.id, name });
|
|
47384
|
+
}
|
|
47385
|
+
return persons;
|
|
47386
|
+
}
|
|
47387
|
+
function resolvePersonByName(persons, query) {
|
|
47388
|
+
const q2 = normalizePersonName(query);
|
|
47389
|
+
if (!q2)
|
|
47390
|
+
return null;
|
|
47391
|
+
const exact = persons.filter((p2) => normalizePersonName(p2.name) === q2);
|
|
47392
|
+
if (exact.length === 1)
|
|
47393
|
+
return { personId: exact[0].id };
|
|
47394
|
+
if (exact.length > 1)
|
|
47395
|
+
return { candidates: exact.map((p2) => p2.name) };
|
|
47396
|
+
const partial2 = persons.filter((p2) => normalizePersonName(p2.name).includes(q2));
|
|
47397
|
+
if (partial2.length === 1)
|
|
47398
|
+
return { personId: partial2[0].id };
|
|
47399
|
+
if (partial2.length > 1)
|
|
47400
|
+
return { candidates: partial2.map((p2) => p2.name) };
|
|
47401
|
+
return null;
|
|
47402
|
+
}
|
|
47403
|
+
function parseNarration(description) {
|
|
47404
|
+
const hdfc = parseHdfcNarration(description);
|
|
47405
|
+
if (hdfc.name && hdfc.paymentMethod)
|
|
47406
|
+
return { name: hdfc.name, upiId: hdfc.upiId, paymentMethod: hdfc.paymentMethod };
|
|
47407
|
+
const sbi = parseSbiNarration(description);
|
|
47408
|
+
if (sbi.name && sbi.paymentMethod)
|
|
47409
|
+
return { name: sbi.name, upiId: sbi.upiId, paymentMethod: sbi.paymentMethod };
|
|
46637
47410
|
return {
|
|
46638
|
-
|
|
46639
|
-
|
|
46640
|
-
|
|
46641
|
-
referenceNumber: null,
|
|
46642
|
-
description: null
|
|
47411
|
+
name: hdfc.name || sbi.name,
|
|
47412
|
+
upiId: hdfc.upiId ?? sbi.upiId,
|
|
47413
|
+
paymentMethod: hdfc.paymentMethod ?? sbi.paymentMethod
|
|
46643
47414
|
};
|
|
46644
47415
|
}
|
|
46645
|
-
function
|
|
46646
|
-
|
|
47416
|
+
function isPersonLikeNarration(description) {
|
|
47417
|
+
const { name, paymentMethod } = parseNarration(description);
|
|
47418
|
+
return classifyEntity({ paymentMethod, extractedName: name }) !== "merchant";
|
|
46647
47419
|
}
|
|
46648
|
-
|
|
46649
|
-
|
|
46650
|
-
|
|
46651
|
-
const
|
|
46652
|
-
|
|
46653
|
-
|
|
46654
|
-
|
|
46655
|
-
|
|
46656
|
-
|
|
46657
|
-
|
|
46658
|
-
|
|
46659
|
-
|
|
46660
|
-
}
|
|
46661
|
-
|
|
46662
|
-
|
|
46663
|
-
|
|
46664
|
-
|
|
46665
|
-
|
|
46666
|
-
|
|
46667
|
-
|
|
47420
|
+
async function backfillPersons(client, crypto3, opts = {}) {
|
|
47421
|
+
const existing = await loadDecryptedPersons(client, crypto3);
|
|
47422
|
+
const personIdByNorm = new Map;
|
|
47423
|
+
for (const p2 of existing)
|
|
47424
|
+
personIdByNorm.set(normalizePersonName(p2.name), p2.id);
|
|
47425
|
+
const { rows, truncated } = await fetchAllRowsPaged(client, crypto3, { unresolved: "true" });
|
|
47426
|
+
const candidates = rows.filter((r2) => !r2.merchantId && !r2.personId && !r2.isTransfer && !r2.isIgnored);
|
|
47427
|
+
const groups = new Map;
|
|
47428
|
+
for (const t2 of candidates) {
|
|
47429
|
+
const { name, upiId, paymentMethod } = parseNarration(t2.description);
|
|
47430
|
+
if (!name)
|
|
47431
|
+
continue;
|
|
47432
|
+
if (classifyEntity({ paymentMethod, extractedName: name }) !== "person")
|
|
47433
|
+
continue;
|
|
47434
|
+
const norm = normalizePersonName(name);
|
|
47435
|
+
if (!norm)
|
|
47436
|
+
continue;
|
|
47437
|
+
const g3 = groups.get(norm) ?? {
|
|
47438
|
+
displayName: name.trim(),
|
|
47439
|
+
upiHandles: new Set,
|
|
47440
|
+
txnIds: []
|
|
46668
47441
|
};
|
|
47442
|
+
if (upiId)
|
|
47443
|
+
g3.upiHandles.add(upiId);
|
|
47444
|
+
g3.txnIds.push(t2.id);
|
|
47445
|
+
groups.set(norm, g3);
|
|
46669
47446
|
}
|
|
46670
|
-
|
|
46671
|
-
|
|
46672
|
-
|
|
46673
|
-
|
|
46674
|
-
|
|
47447
|
+
const plan = [];
|
|
47448
|
+
const newGroups = [];
|
|
47449
|
+
let reused = 0;
|
|
47450
|
+
for (const [norm, g3] of groups) {
|
|
47451
|
+
const upiHandles = [...g3.upiHandles];
|
|
47452
|
+
const isReuse = personIdByNorm.has(norm);
|
|
47453
|
+
plan.push({
|
|
47454
|
+
name: g3.displayName,
|
|
47455
|
+
normalized: norm,
|
|
47456
|
+
txnCount: g3.txnIds.length,
|
|
47457
|
+
upiHandles,
|
|
47458
|
+
action: isReuse ? "reuse" : "create"
|
|
47459
|
+
});
|
|
47460
|
+
if (isReuse)
|
|
47461
|
+
reused++;
|
|
47462
|
+
else
|
|
47463
|
+
newGroups.push({ norm, displayName: g3.displayName, upiHandles });
|
|
46675
47464
|
}
|
|
46676
|
-
if (
|
|
46677
|
-
|
|
47465
|
+
if (!opts.dryRun && newGroups.length > 0) {
|
|
47466
|
+
const bodies = await Promise.all(newGroups.map(async ({ displayName, upiHandles }) => {
|
|
47467
|
+
const fields = {
|
|
47468
|
+
name: displayName,
|
|
47469
|
+
upiHandles,
|
|
47470
|
+
phoneNumbers: [],
|
|
47471
|
+
relationship: null,
|
|
47472
|
+
notes: null
|
|
47473
|
+
};
|
|
47474
|
+
return crypto3 ? await encryptPersonCipher(fields, crypto3.key) : { name: displayName, upiHandles, phoneNumbers: [], relationship: null, notes: null };
|
|
47475
|
+
}));
|
|
47476
|
+
const res = await client.post("/api/persons/bulk", {
|
|
47477
|
+
persons: bodies
|
|
47478
|
+
});
|
|
47479
|
+
res.data.forEach((p2, i2) => {
|
|
47480
|
+
personIdByNorm.set(newGroups[i2].norm, p2.id);
|
|
47481
|
+
});
|
|
46678
47482
|
}
|
|
46679
|
-
|
|
46680
|
-
|
|
47483
|
+
const created = opts.dryRun ? 0 : newGroups.length;
|
|
47484
|
+
const links = [];
|
|
47485
|
+
if (!opts.dryRun) {
|
|
47486
|
+
for (const [norm, g3] of groups) {
|
|
47487
|
+
const personId = personIdByNorm.get(norm);
|
|
47488
|
+
if (!personId)
|
|
47489
|
+
continue;
|
|
47490
|
+
for (const txnId of g3.txnIds)
|
|
47491
|
+
links.push({ transactionId: txnId, personId });
|
|
47492
|
+
}
|
|
46681
47493
|
}
|
|
46682
|
-
if (
|
|
46683
|
-
|
|
47494
|
+
if (!opts.dryRun && links.length > 0) {
|
|
47495
|
+
const CHUNK = 25;
|
|
47496
|
+
for (let i2 = 0;i2 < links.length; i2 += CHUNK) {
|
|
47497
|
+
await client.post("/api/transactions/bulk-link-entity", { links: links.slice(i2, i2 + CHUNK) });
|
|
47498
|
+
}
|
|
46684
47499
|
}
|
|
46685
|
-
return {
|
|
47500
|
+
return {
|
|
47501
|
+
dryRun: !!opts.dryRun,
|
|
47502
|
+
truncated,
|
|
47503
|
+
candidatesScanned: candidates.length,
|
|
47504
|
+
created,
|
|
47505
|
+
reused,
|
|
47506
|
+
linked: links.length,
|
|
47507
|
+
plan
|
|
47508
|
+
};
|
|
46686
47509
|
}
|
|
46687
|
-
|
|
46688
|
-
|
|
46689
|
-
|
|
47510
|
+
|
|
47511
|
+
// src/lib/categorize-groups.ts
|
|
47512
|
+
var NOISE2 = new Set([
|
|
47513
|
+
"upi",
|
|
47514
|
+
"pos",
|
|
47515
|
+
"imps",
|
|
47516
|
+
"neft",
|
|
47517
|
+
"rtgs",
|
|
47518
|
+
"ach",
|
|
47519
|
+
"nach",
|
|
47520
|
+
"ecs",
|
|
47521
|
+
"txn",
|
|
47522
|
+
"transaction",
|
|
47523
|
+
"payment",
|
|
47524
|
+
"paid",
|
|
47525
|
+
"pay",
|
|
47526
|
+
"to",
|
|
47527
|
+
"from",
|
|
47528
|
+
"ref",
|
|
47529
|
+
"refno",
|
|
47530
|
+
"debit",
|
|
47531
|
+
"credit",
|
|
47532
|
+
"card",
|
|
47533
|
+
"mob",
|
|
47534
|
+
"mobile",
|
|
47535
|
+
"bank",
|
|
47536
|
+
"transfer",
|
|
47537
|
+
"trf",
|
|
47538
|
+
"by",
|
|
47539
|
+
"for",
|
|
47540
|
+
"the",
|
|
47541
|
+
"via",
|
|
47542
|
+
"ecom",
|
|
47543
|
+
"purchase",
|
|
47544
|
+
"online",
|
|
47545
|
+
"inr",
|
|
47546
|
+
"rs",
|
|
47547
|
+
"towards",
|
|
47548
|
+
"ibl",
|
|
47549
|
+
"ybl",
|
|
47550
|
+
"oksbi",
|
|
47551
|
+
"okhdfcbank",
|
|
47552
|
+
"okicici",
|
|
47553
|
+
"okaxis",
|
|
47554
|
+
"axl",
|
|
47555
|
+
"apl",
|
|
47556
|
+
"paytm",
|
|
47557
|
+
"icici",
|
|
47558
|
+
"hdfc",
|
|
47559
|
+
"sbi",
|
|
47560
|
+
"axis",
|
|
47561
|
+
"utib",
|
|
47562
|
+
"sbin",
|
|
47563
|
+
"ifsc",
|
|
47564
|
+
"vpa",
|
|
47565
|
+
"bill",
|
|
47566
|
+
"cas",
|
|
47567
|
+
"dr",
|
|
47568
|
+
"cr"
|
|
47569
|
+
]);
|
|
47570
|
+
var GOOD_TOKEN = /^[a-z][a-z&']{2,}$/;
|
|
47571
|
+
var MAX_RUN = 3;
|
|
47572
|
+
function descriptionPattern(description) {
|
|
47573
|
+
const tokens = normaliseName(description).split(" ").filter(Boolean);
|
|
47574
|
+
const run = [];
|
|
47575
|
+
for (const tok of tokens) {
|
|
47576
|
+
const good = GOOD_TOKEN.test(tok) && !NOISE2.has(tok);
|
|
47577
|
+
if (!good) {
|
|
47578
|
+
if (run.length)
|
|
47579
|
+
break;
|
|
47580
|
+
continue;
|
|
47581
|
+
}
|
|
47582
|
+
if (run.includes(tok))
|
|
47583
|
+
break;
|
|
47584
|
+
run.push(tok);
|
|
47585
|
+
if (run.length === MAX_RUN)
|
|
47586
|
+
break;
|
|
47587
|
+
}
|
|
47588
|
+
return run.join(" ");
|
|
47589
|
+
}
|
|
47590
|
+
var UNPARSED = "(unparsed)";
|
|
47591
|
+
function groupRows(rows) {
|
|
47592
|
+
const map2 = new Map;
|
|
47593
|
+
for (const r2 of rows) {
|
|
47594
|
+
const pattern = descriptionPattern(r2.description) || UNPARSED;
|
|
47595
|
+
const key = `${pattern}|${r2.type}`;
|
|
47596
|
+
const g3 = map2.get(key) ?? { pattern, type: r2.type, rows: [], total: 0 };
|
|
47597
|
+
g3.rows.push(r2);
|
|
47598
|
+
g3.total += r2.amount;
|
|
47599
|
+
map2.set(key, g3);
|
|
47600
|
+
}
|
|
47601
|
+
return [...map2.values()].sort((a2, b2) => b2.total - a2.total);
|
|
47602
|
+
}
|
|
47603
|
+
function isCategorizable(r2) {
|
|
47604
|
+
return !r2.categoryId && !r2.isTransfer && !r2.isIgnored && !r2.isDuplicate;
|
|
47605
|
+
}
|
|
47606
|
+
async function fetchUncategorized(client, crypto3, range2 = {}) {
|
|
47607
|
+
const params = { uncategorized: "true" };
|
|
47608
|
+
if (range2.startDate)
|
|
47609
|
+
params.startDate = range2.startDate;
|
|
47610
|
+
if (range2.endDate)
|
|
47611
|
+
params.endDate = range2.endDate;
|
|
47612
|
+
return (await fetchAllRows(client, crypto3, params)).filter(isCategorizable);
|
|
47613
|
+
}
|
|
47614
|
+
var MERCHANT_CREATE_CONCURRENCY = 5;
|
|
47615
|
+
var titleCase = (s2) => s2.replace(/\b[a-z]/g, (c2) => c2.toUpperCase());
|
|
47616
|
+
async function applyGroupAssignments(client, crypto3, assignments, opts = {}) {
|
|
47617
|
+
const catIndex = await loadCategoryIndex(client);
|
|
47618
|
+
const resolved = assignments.map((a2) => ({ a: a2, cat: requireCategory(catIndex, a2.categorySlug) }));
|
|
47619
|
+
const groups = groupRows(await fetchUncategorized(client, crypto3, opts));
|
|
47620
|
+
const result = { applied: [], notFound: [], rulesSaved: 0, unlearnable: [] };
|
|
47621
|
+
const plan = [];
|
|
47622
|
+
const claimed = new Set;
|
|
47623
|
+
for (const { a: a2, cat } of resolved) {
|
|
47624
|
+
const hits = groups.filter((g3) => g3.pattern === a2.pattern && (!a2.type || g3.type === a2.type) && !claimed.has(g3));
|
|
47625
|
+
if (hits.length === 0) {
|
|
47626
|
+
result.notFound.push(a2.pattern);
|
|
47627
|
+
continue;
|
|
47628
|
+
}
|
|
47629
|
+
for (const g3 of hits) {
|
|
47630
|
+
claimed.add(g3);
|
|
47631
|
+
plan.push({ group: g3, categoryId: cat.id, slug: cat.slug, learn: a2.learn !== false });
|
|
47632
|
+
}
|
|
47633
|
+
}
|
|
47634
|
+
if (opts.dryRun) {
|
|
47635
|
+
result.applied = plan.map((p2) => ({
|
|
47636
|
+
pattern: p2.group.pattern,
|
|
47637
|
+
type: p2.group.type,
|
|
47638
|
+
rows: p2.group.rows.length,
|
|
47639
|
+
category: p2.slug
|
|
47640
|
+
}));
|
|
47641
|
+
return result;
|
|
47642
|
+
}
|
|
47643
|
+
if (plan.length === 0)
|
|
47644
|
+
return result;
|
|
47645
|
+
const learnable = plan.filter((p2) => p2.learn && p2.group.pattern !== UNPARSED);
|
|
47646
|
+
const merchantByRawId = new Map;
|
|
47647
|
+
if (learnable.length > 0) {
|
|
47648
|
+
const ms = await client.get("/api/merchants");
|
|
47649
|
+
for (const m2 of ms.data ?? [])
|
|
47650
|
+
if (m2.rawId && m2.householdId)
|
|
47651
|
+
merchantByRawId.set(m2.rawId, m2.id);
|
|
47652
|
+
}
|
|
47653
|
+
const { bag, version: version2 } = await loadBag(client);
|
|
47654
|
+
const now = new Date().toISOString();
|
|
47655
|
+
const links = [];
|
|
47656
|
+
const direct = [];
|
|
47657
|
+
const taught = new Map;
|
|
47658
|
+
const learnPlans = [];
|
|
47659
|
+
const directPlans = [];
|
|
47660
|
+
for (const p2 of plan) {
|
|
47661
|
+
const { group, learn } = p2;
|
|
47662
|
+
if (!learn || group.pattern === UNPARSED) {
|
|
47663
|
+
directPlans.push({ p: p2, reason: learn ? UNPARSED : undefined });
|
|
47664
|
+
} else if (crypto3 && group.rows.some((r2) => isPersonLikeNarration(r2.description))) {
|
|
47665
|
+
directPlans.push({ p: p2, reason: `${group.pattern} (private mode: person-like)` });
|
|
47666
|
+
} else if (taught.has(group.pattern) && taught.get(group.pattern) !== p2.categoryId) {
|
|
47667
|
+
directPlans.push({ p: p2, reason: `${group.pattern} (conflicting category for same pattern)` });
|
|
47668
|
+
} else {
|
|
47669
|
+
taught.set(group.pattern, p2.categoryId);
|
|
47670
|
+
learnPlans.push(p2);
|
|
47671
|
+
}
|
|
47672
|
+
}
|
|
47673
|
+
for (const { p: p2, reason } of directPlans) {
|
|
47674
|
+
if (reason)
|
|
47675
|
+
result.unlearnable.push(reason);
|
|
47676
|
+
direct.push({ ids: p2.group.rows.map((r2) => r2.id), categoryId: p2.categoryId });
|
|
47677
|
+
result.applied.push({
|
|
47678
|
+
pattern: p2.group.pattern,
|
|
47679
|
+
type: p2.group.type,
|
|
47680
|
+
rows: p2.group.rows.length,
|
|
47681
|
+
category: p2.slug
|
|
47682
|
+
});
|
|
47683
|
+
}
|
|
47684
|
+
const toCreate = new Map;
|
|
47685
|
+
for (const p2 of learnPlans) {
|
|
47686
|
+
const rawId = `pattern:${p2.group.pattern}`;
|
|
47687
|
+
if (!merchantByRawId.has(rawId) && !toCreate.has(rawId))
|
|
47688
|
+
toCreate.set(rawId, p2);
|
|
47689
|
+
}
|
|
47690
|
+
await mapLimit([...toCreate], MERCHANT_CREATE_CONCURRENCY, async ([rawId, p2]) => {
|
|
47691
|
+
const created = await client.post("/api/merchants", {
|
|
47692
|
+
rawId,
|
|
47693
|
+
cleanName: titleCase(p2.group.pattern),
|
|
47694
|
+
categoryId: p2.categoryId
|
|
47695
|
+
});
|
|
47696
|
+
merchantByRawId.set(rawId, created.data.id);
|
|
47697
|
+
});
|
|
47698
|
+
for (const { group, categoryId, slug } of learnPlans) {
|
|
47699
|
+
result.applied.push({
|
|
47700
|
+
pattern: group.pattern,
|
|
47701
|
+
type: group.type,
|
|
47702
|
+
rows: group.rows.length,
|
|
47703
|
+
category: slug
|
|
47704
|
+
});
|
|
47705
|
+
const merchantId = merchantByRawId.get(`pattern:${group.pattern}`);
|
|
47706
|
+
if (!bag.merchantAliases.some((al) => al.pattern === group.pattern)) {
|
|
47707
|
+
bag.merchantAliases.push({ pattern: group.pattern, merchantId, createdAt: now });
|
|
47708
|
+
}
|
|
47709
|
+
bag.merchantRules[merchantId] = { categoryId, lastUserCategoryAt: now };
|
|
47710
|
+
result.rulesSaved += 1;
|
|
47711
|
+
const withPerson = group.rows.filter((r2) => r2.personId);
|
|
47712
|
+
if (withPerson.length)
|
|
47713
|
+
direct.push({ ids: withPerson.map((r2) => r2.id), categoryId });
|
|
47714
|
+
for (const r2 of group.rows) {
|
|
47715
|
+
if (!r2.personId)
|
|
47716
|
+
links.push({ transactionId: r2.id, merchantId, categoryId });
|
|
47717
|
+
}
|
|
47718
|
+
}
|
|
47719
|
+
if (result.rulesSaved > 0)
|
|
47720
|
+
await saveBag(client, bag, version2);
|
|
47721
|
+
await postLinks(client, links);
|
|
47722
|
+
for (const { ids, categoryId } of direct) {
|
|
47723
|
+
const categoryType = catIndex.byId.get(categoryId)?.type;
|
|
47724
|
+
for (let i2 = 0;i2 < ids.length; i2 += 200) {
|
|
47725
|
+
await client.patch("/api/transactions/bulk", {
|
|
47726
|
+
ids: ids.slice(i2, i2 + 200),
|
|
47727
|
+
categoryId,
|
|
47728
|
+
...categoryType ? { categoryType } : {}
|
|
47729
|
+
});
|
|
47730
|
+
}
|
|
47731
|
+
}
|
|
47732
|
+
return result;
|
|
47733
|
+
}
|
|
47734
|
+
|
|
47735
|
+
// src/lib/jev-categorize.ts
|
|
47736
|
+
var JEV_MODEL = "jev-1.13.0";
|
|
47737
|
+
var DEFAULT_MIN_CONFIDENCE = 0.95;
|
|
47738
|
+
var TIMEOUT_MS = 8000;
|
|
47739
|
+
var CONCURRENCY = 8;
|
|
47740
|
+
var MIN_LEARN_PATTERN_LEN = 5;
|
|
47741
|
+
function assertPrivateModeAllowed(crypto3, env2 = process.env) {
|
|
47742
|
+
if (crypto3 && env2.JEV_ALLOW_PRIVATE_MODE !== "1") {
|
|
47743
|
+
throw new Error("Jev categorization is disabled in private mode: it would send transaction text to a third party. " + "To opt in for your own account, set JEV_ALLOW_PRIVATE_MODE=1 in the MCP's environment. " + "Otherwise use get_uncategorized_groups + categorize_groups.");
|
|
47744
|
+
}
|
|
47745
|
+
}
|
|
47746
|
+
function assertJevAllowed(crypto3, env2 = process.env) {
|
|
47747
|
+
assertPrivateModeAllowed(crypto3, env2);
|
|
47748
|
+
const key = env2.JEV_API_KEY;
|
|
47749
|
+
if (!key)
|
|
47750
|
+
throw new Error("JEV_API_KEY is not set — Jev categorization is off.");
|
|
47751
|
+
return key;
|
|
47752
|
+
}
|
|
47753
|
+
function makeJevClassifier(apiKey) {
|
|
47754
|
+
const client = new TypeSafeClient({ apiKey, timeout: TIMEOUT_MS });
|
|
47755
|
+
return async (state, options) => {
|
|
47756
|
+
const { answers } = await client.systemOne({
|
|
47757
|
+
model: JEV_MODEL,
|
|
47758
|
+
state,
|
|
47759
|
+
questions: {
|
|
47760
|
+
category: choice("Which spending category best fits this bank transaction?", options)
|
|
47761
|
+
}
|
|
47762
|
+
});
|
|
47763
|
+
return { choice: answers.category.choice, confidence: answers.category.confidence };
|
|
47764
|
+
};
|
|
47765
|
+
}
|
|
47766
|
+
var UNSURE = "unsure";
|
|
47767
|
+
function optionsFor(type, index) {
|
|
47768
|
+
const allowed = type === "credit" ? new Set(["income", "transfer"]) : new Set(["needs", "wants", "investments", "transfer"]);
|
|
47769
|
+
const out = {};
|
|
47770
|
+
for (const [slug, c2] of index.bySlug) {
|
|
47771
|
+
if (index.parents.has(slug) || !allowed.has(c2.type))
|
|
47772
|
+
continue;
|
|
47773
|
+
out[slug] = `${c2.type}: ${slug.replace(/_/g, " ")}`;
|
|
47774
|
+
}
|
|
47775
|
+
out[UNSURE] = "unclear, or a payment to an individual person";
|
|
47776
|
+
return out;
|
|
47777
|
+
}
|
|
47778
|
+
function groupState(type, sample) {
|
|
47779
|
+
const dir = type === "credit" ? "Money received" : "Money paid out";
|
|
47780
|
+
return `${dir}. Bank narration: "${sample.slice(0, 80)}"`;
|
|
47781
|
+
}
|
|
47782
|
+
async function jevCategorize(client, crypto3, classify, opts = {}) {
|
|
47783
|
+
assertPrivateModeAllowed(crypto3, opts.env);
|
|
47784
|
+
const min = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
|
|
47785
|
+
const index = await loadCategoryIndex(client);
|
|
47786
|
+
const rows = await fetchUncategorized(client, crypto3, opts);
|
|
47787
|
+
const groups = groupRows(rows);
|
|
47788
|
+
const unparsed = groups.filter((g3) => g3.pattern === UNPARSED).length;
|
|
47789
|
+
const candidates = groups.filter((g3) => g3.pattern !== UNPARSED);
|
|
47790
|
+
let errors4 = 0;
|
|
47791
|
+
const suggestions = await mapLimit(candidates, CONCURRENCY, async (g3) => {
|
|
47792
|
+
try {
|
|
47793
|
+
const options = optionsFor(g3.type, index);
|
|
47794
|
+
const sample = g3.rows[0].description;
|
|
47795
|
+
const text = crypto3 ? g3.pattern : sample;
|
|
47796
|
+
const r2 = await classify(groupState(g3.type, text), options);
|
|
47797
|
+
return r2.choice in options ? { g: g3, ...r2 } : null;
|
|
47798
|
+
} catch {
|
|
47799
|
+
errors4 += 1;
|
|
47800
|
+
return null;
|
|
47801
|
+
}
|
|
47802
|
+
});
|
|
47803
|
+
const accepted = [];
|
|
47804
|
+
const review = [];
|
|
47805
|
+
for (const s2 of suggestions) {
|
|
47806
|
+
if (!s2)
|
|
47807
|
+
continue;
|
|
47808
|
+
const isTransfer = index.bySlug.get(s2.choice)?.type === "transfer";
|
|
47809
|
+
if (s2.choice !== UNSURE && !isTransfer && s2.confidence >= min) {
|
|
47810
|
+
accepted.push({
|
|
47811
|
+
pattern: s2.g.pattern,
|
|
47812
|
+
type: s2.g.type,
|
|
47813
|
+
categorySlug: s2.choice,
|
|
47814
|
+
learn: s2.g.pattern.length >= MIN_LEARN_PATTERN_LEN,
|
|
47815
|
+
confidence: s2.confidence
|
|
47816
|
+
});
|
|
47817
|
+
} else {
|
|
47818
|
+
review.push({
|
|
47819
|
+
pattern: s2.g.pattern,
|
|
47820
|
+
type: s2.g.type === "credit" ? "cr" : "dr",
|
|
47821
|
+
best: s2.choice,
|
|
47822
|
+
confidence: Math.round(s2.confidence * 100) / 100,
|
|
47823
|
+
total: Math.round(s2.g.total)
|
|
47824
|
+
});
|
|
47825
|
+
}
|
|
47826
|
+
}
|
|
47827
|
+
review.sort((a2, b2) => b2.total - a2.total);
|
|
47828
|
+
const applied = await applyGroupAssignments(client, crypto3, accepted.map(({ confidence: _c, ...a2 }) => a2), { dryRun: opts.dryRun, startDate: opts.startDate, endDate: opts.endDate });
|
|
47829
|
+
const conf = new Map(accepted.map((a2) => [`${a2.pattern}|${a2.type}`, a2.confidence]));
|
|
47830
|
+
return {
|
|
47831
|
+
dryRun: !!opts.dryRun,
|
|
47832
|
+
applied: applied.applied.map((a2) => ({
|
|
47833
|
+
pattern: a2.pattern,
|
|
47834
|
+
category: a2.category,
|
|
47835
|
+
confidence: Math.round((conf.get(`${a2.pattern}|${a2.type}`) ?? 0) * 100) / 100,
|
|
47836
|
+
rows: a2.rows
|
|
47837
|
+
})),
|
|
47838
|
+
review: review.slice(0, 30),
|
|
47839
|
+
skipped: { unparsed, errors: errors4 },
|
|
47840
|
+
rulesSaved: applied.rulesSaved
|
|
47841
|
+
};
|
|
47842
|
+
}
|
|
47843
|
+
|
|
47844
|
+
// src/tools/jev.ts
|
|
47845
|
+
function registerJevTools(server, client, crypto3) {
|
|
47846
|
+
server.registerTool("auto_categorize_jev", {
|
|
47847
|
+
description: "Auto-categorize uncategorized transactions with Jev (TypeSafe's classifier) — zero LLM tokens, ~ms per group. " + "OPT-IN: needs JEV_API_KEY. Refuses in private mode (it would send transaction text to a third party) unless the owner sets JEV_ALLOW_PRIVATE_MODE=1; in private mode only the sanitized merchant pattern is sent. " + `Groups by merchant pattern; groups at/above \`minConfidence\` (default ${DEFAULT_MIN_CONFIDENCE}) are categorized and a reusable rule is saved; ` + "the rest come back in `review` (top 30 by ₹) for you to decide with `categorize_groups`. Use dryRun to preview.",
|
|
47848
|
+
inputSchema: {
|
|
47849
|
+
startDate: exports_external.string().optional().describe("YYYY-MM-DD"),
|
|
47850
|
+
endDate: exports_external.string().optional().describe("YYYY-MM-DD"),
|
|
47851
|
+
minConfidence: exports_external.number().min(0.5).max(1).optional().describe(`Apply at/above this confidence (default ${DEFAULT_MIN_CONFIDENCE})`),
|
|
47852
|
+
dryRun: exports_external.boolean().optional()
|
|
47853
|
+
}
|
|
47854
|
+
}, async ({ startDate, endDate, minConfidence, dryRun }) => {
|
|
47855
|
+
const key = assertJevAllowed(crypto3);
|
|
47856
|
+
const result = await jevCategorize(client, crypto3, makeJevClassifier(key), {
|
|
47857
|
+
startDate,
|
|
47858
|
+
endDate,
|
|
47859
|
+
minConfidence,
|
|
47860
|
+
dryRun
|
|
47861
|
+
});
|
|
47862
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
47863
|
+
});
|
|
47864
|
+
}
|
|
47865
|
+
|
|
47866
|
+
// src/tools/merchants.ts
|
|
47867
|
+
async function categorySlugToId(client) {
|
|
47868
|
+
const res = await client.get("/api/categories");
|
|
47869
|
+
const map2 = new Map;
|
|
47870
|
+
for (const root of res.data ?? []) {
|
|
47871
|
+
if (root.slug)
|
|
47872
|
+
map2.set(root.slug, root.id);
|
|
47873
|
+
for (const child of root.children ?? []) {
|
|
47874
|
+
if (child.slug)
|
|
47875
|
+
map2.set(child.slug, child.id);
|
|
47876
|
+
}
|
|
47877
|
+
}
|
|
47878
|
+
return map2;
|
|
47879
|
+
}
|
|
47880
|
+
function registerMerchantTools(server, client, crypto3) {
|
|
47881
|
+
server.registerTool("upsert_merchant", {
|
|
47882
|
+
description: "Create or update a merchant in the dictionary. Omit `id` to add a new merchant (future transactions from it are auto-categorised); pass `id` to update an existing merchant's name, category, or flags.",
|
|
47883
|
+
inputSchema: {
|
|
47884
|
+
id: exports_external.string().optional().describe("Merchant UUID — pass to update, omit to create"),
|
|
47885
|
+
rawId: exports_external.string().optional().describe("Raw identifier — UPI ID or normalised name (required when creating)"),
|
|
47886
|
+
cleanName: exports_external.string().optional().describe('Human-friendly name (e.g. "DMart")'),
|
|
47887
|
+
categorySlug: exports_external.string().optional().describe("Category slug, e.g. 'food_delivery'"),
|
|
47888
|
+
isPerson: exports_external.boolean().optional(),
|
|
47889
|
+
isRecurring: exports_external.boolean().optional(),
|
|
47890
|
+
notes: exports_external.string().optional()
|
|
47891
|
+
}
|
|
47892
|
+
}, async ({ id, categorySlug, ...rest }) => {
|
|
47893
|
+
const payload = { ...rest };
|
|
47894
|
+
if (categorySlug) {
|
|
47895
|
+
const slugMap = await categorySlugToId(client);
|
|
47896
|
+
const categoryId = slugMap.get(categorySlug);
|
|
47897
|
+
if (!categoryId) {
|
|
47898
|
+
throw new Error(`Unknown categorySlug "${categorySlug}". Use list_categories to see valid slugs.`);
|
|
47899
|
+
}
|
|
47900
|
+
payload.categoryId = categoryId;
|
|
47901
|
+
}
|
|
47902
|
+
const data = id ? await client.patch(`/api/merchants/${id}`, payload) : await client.post("/api/merchants", payload);
|
|
47903
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
47904
|
+
});
|
|
47905
|
+
server.registerTool("list_merchants", {
|
|
47906
|
+
description: "List merchants in the dictionary with their categories and transaction counts. Pass `rawId` to look up a single merchant by its raw ID (UPI ID or normalised name) instead of listing all (matched client-side).",
|
|
47907
|
+
inputSchema: {
|
|
47908
|
+
rawId: exports_external.string().optional().describe("Raw identifier to look up a single merchant; omit to list all")
|
|
47909
|
+
}
|
|
47910
|
+
}, async ({ rawId }) => {
|
|
47911
|
+
const data = await client.get("/api/merchants");
|
|
47912
|
+
if (crypto3 && data.data)
|
|
47913
|
+
await decryptMerchantFields(data.data, crypto3.key);
|
|
47914
|
+
if (!rawId)
|
|
47915
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
47916
|
+
const needle = rawId.toLowerCase();
|
|
47917
|
+
const merchants = (data.data ?? []).filter((m2) => String(m2.rawId ?? "").toLowerCase() === needle || String(m2.cleanName ?? "").toLowerCase().includes(needle));
|
|
47918
|
+
return {
|
|
47919
|
+
content: [{ type: "text", text: JSON.stringify({ ...data, data: merchants }) }]
|
|
47920
|
+
};
|
|
47921
|
+
});
|
|
47922
|
+
server.registerTool("merge_merchants", {
|
|
47923
|
+
description: "Merge duplicate merchants into one primary merchant. Transactions of the duplicates are re-pointed to the primary (unlike delete_merchant, which unlinks them). Duplicates are removed.",
|
|
47924
|
+
inputSchema: {
|
|
47925
|
+
primaryId: exports_external.string().describe("UUID of the merchant to keep"),
|
|
47926
|
+
duplicateIds: exports_external.array(exports_external.string()).min(1).describe("UUIDs of duplicates to fold into it")
|
|
47927
|
+
}
|
|
47928
|
+
}, async (body) => {
|
|
47929
|
+
const data = await client.post("/api/merchants/merge", body);
|
|
47930
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
47931
|
+
});
|
|
47932
|
+
server.registerTool("delete_merchant", {
|
|
47933
|
+
description: "Delete merchant(s) from the dictionary. Provide `id` for a single merchant or `ids` for several. Transactions referencing a deleted merchant have their merchantId cleared. PERMANENT.",
|
|
47934
|
+
inputSchema: {
|
|
47935
|
+
id: exports_external.string().optional().describe("Single merchant UUID"),
|
|
47936
|
+
ids: exports_external.array(exports_external.string()).min(1).optional().describe("Multiple merchant UUIDs (bulk delete)")
|
|
47937
|
+
}
|
|
47938
|
+
}, async ({ id, ids }) => {
|
|
47939
|
+
if (!id && (!ids || ids.length === 0)) {
|
|
47940
|
+
throw new Error("Provide either id or ids");
|
|
47941
|
+
}
|
|
47942
|
+
const data = ids ? await client.post("/api/merchants/bulk-delete", { ids }) : await client.delete(`/api/merchants/${id}`);
|
|
47943
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
47944
|
+
});
|
|
47945
|
+
}
|
|
47946
|
+
|
|
47947
|
+
// src/tools/net-worth.ts
|
|
47948
|
+
function registerNetWorthTools(server, client) {
|
|
47949
|
+
server.registerTool("get_net_worth", {
|
|
47950
|
+
description: "Current net worth snapshot and history (up to 12 months). Includes liquid cash, investments, emergency fund, liabilities.",
|
|
47951
|
+
inputSchema: {}
|
|
47952
|
+
}, async () => {
|
|
47953
|
+
const data = await client.get("/api/net-worth");
|
|
47954
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
47955
|
+
});
|
|
47956
|
+
server.registerTool("update_net_worth", {
|
|
47957
|
+
description: "Log a net worth snapshot for a specific month. Upserts — safe to call multiple times for the same month. mode=manual (default) records the provided (omitted ones default to 0) liquidCash/investments/emergencyFund/liabilities/breakdown values. mode=auto ignores those and auto-calculates net worth from DB (investments + FDs + accounts) — use it when the data is already in the system.",
|
|
47958
|
+
inputSchema: {
|
|
47959
|
+
mode: exports_external.enum(["manual", "auto"]).default("manual"),
|
|
47960
|
+
month: exports_external.number().min(1).max(12),
|
|
47961
|
+
year: exports_external.number().min(2020).max(2100),
|
|
47962
|
+
liquidCash: exports_external.string().optional().describe('manual mode: defaults to "0"'),
|
|
47963
|
+
investments: exports_external.string().optional().describe('manual mode: defaults to "0"'),
|
|
47964
|
+
emergencyFund: exports_external.string().optional().describe('manual mode: defaults to "0"'),
|
|
47965
|
+
liabilities: exports_external.string().optional(),
|
|
47966
|
+
breakdown: exports_external.record(exports_external.string(), exports_external.string()).optional()
|
|
47967
|
+
}
|
|
47968
|
+
}, async ({ mode, ...body }) => {
|
|
47969
|
+
let data;
|
|
47970
|
+
if (mode === "auto") {
|
|
47971
|
+
data = await client.post("/api/net-worth/auto", { month: body.month, year: body.year });
|
|
47972
|
+
} else {
|
|
47973
|
+
const liquidCash = parseFloat(body.liquidCash ?? "0");
|
|
47974
|
+
const investments = parseFloat(body.investments ?? "0");
|
|
47975
|
+
const emergencyFund = parseFloat(body.emergencyFund ?? "0");
|
|
47976
|
+
const liabilities = parseFloat(body.liabilities ?? "0");
|
|
47977
|
+
const totalNetWorth = (liquidCash + investments + emergencyFund - liabilities).toFixed(2);
|
|
47978
|
+
data = await client.post("/api/net-worth", {
|
|
47979
|
+
...body,
|
|
47980
|
+
liquidCash: body.liquidCash ?? "0",
|
|
47981
|
+
investments: body.investments ?? "0",
|
|
47982
|
+
emergencyFund: body.emergencyFund ?? "0",
|
|
47983
|
+
totalNetWorth
|
|
47984
|
+
});
|
|
47985
|
+
}
|
|
47986
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
47987
|
+
});
|
|
47988
|
+
}
|
|
47989
|
+
|
|
47990
|
+
// src/tools/persons.ts
|
|
47991
|
+
function registerPersonTools(server, client, crypto3) {
|
|
47992
|
+
server.registerTool("upsert_person", {
|
|
47993
|
+
description: "Create or update a person (UPI counterparty / individual) in this household's encrypted directory. Omit `id` to add a new person; pass `id` to update any combination of an existing person's fields.",
|
|
47994
|
+
inputSchema: {
|
|
47995
|
+
id: exports_external.string().optional().describe("Person UUID — pass to update, omit to create"),
|
|
47996
|
+
name: exports_external.string().optional().describe('Display name (e.g. "Priya") — required when creating'),
|
|
47997
|
+
upiHandles: exports_external.array(exports_external.string()).optional().describe("UPI ids the person uses"),
|
|
47998
|
+
phoneNumbers: exports_external.array(exports_external.string()).optional(),
|
|
47999
|
+
relationship: exports_external.string().optional().describe('e.g. "sister", "friend", "landlord"'),
|
|
48000
|
+
notes: exports_external.string().optional()
|
|
48001
|
+
}
|
|
48002
|
+
}, async ({ id, ...updates }) => {
|
|
48003
|
+
if (crypto3) {
|
|
48004
|
+
if (id) {
|
|
48005
|
+
const existing = await client.get(`/api/persons/${id}`);
|
|
48006
|
+
const personData = existing.data;
|
|
48007
|
+
let current;
|
|
48008
|
+
if (personData.cipher) {
|
|
48009
|
+
current = await decryptPersonCipher(personData.cipher, crypto3.key);
|
|
48010
|
+
} else {
|
|
48011
|
+
current = {
|
|
48012
|
+
name: personData.name,
|
|
48013
|
+
upiHandles: personData.upiHandles,
|
|
48014
|
+
phoneNumbers: personData.phoneNumbers,
|
|
48015
|
+
relationship: personData.relationship,
|
|
48016
|
+
notes: personData.notes
|
|
48017
|
+
};
|
|
48018
|
+
}
|
|
48019
|
+
const merged = {
|
|
48020
|
+
name: updates.name ?? current.name,
|
|
48021
|
+
upiHandles: updates.upiHandles ?? current.upiHandles,
|
|
48022
|
+
phoneNumbers: updates.phoneNumbers ?? current.phoneNumbers,
|
|
48023
|
+
relationship: updates.relationship ?? current.relationship ?? null,
|
|
48024
|
+
notes: updates.notes ?? current.notes ?? null
|
|
48025
|
+
};
|
|
48026
|
+
const payload2 = await encryptPersonCipher(merged, crypto3.key);
|
|
48027
|
+
const data3 = await client.patch(`/api/persons/${id}`, payload2);
|
|
48028
|
+
return { content: [{ type: "text", text: JSON.stringify(data3) }] };
|
|
48029
|
+
}
|
|
48030
|
+
if (!updates.name)
|
|
48031
|
+
throw new Error("name is required when creating a person");
|
|
48032
|
+
const fields = {
|
|
48033
|
+
name: updates.name,
|
|
48034
|
+
upiHandles: updates.upiHandles ?? [],
|
|
48035
|
+
phoneNumbers: updates.phoneNumbers ?? [],
|
|
48036
|
+
relationship: updates.relationship ?? null,
|
|
48037
|
+
notes: updates.notes ?? null
|
|
48038
|
+
};
|
|
48039
|
+
const payload = await encryptPersonCipher(fields, crypto3.key);
|
|
48040
|
+
const data2 = await client.post("/api/persons", payload);
|
|
48041
|
+
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
48042
|
+
}
|
|
48043
|
+
const data = id ? await client.patch(`/api/persons/${id}`, updates) : await client.post("/api/persons", updates);
|
|
48044
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48045
|
+
});
|
|
48046
|
+
server.registerTool("list_persons", {
|
|
48047
|
+
description: "List all persons in the current household. Pass `id` to get a single person by id instead of listing all.",
|
|
48048
|
+
inputSchema: {
|
|
48049
|
+
id: exports_external.string().optional().describe("Person UUID to fetch a single person; omit to list all")
|
|
48050
|
+
}
|
|
48051
|
+
}, async ({ id }) => {
|
|
48052
|
+
if (id) {
|
|
48053
|
+
const data2 = await client.get(`/api/persons/${id}`);
|
|
48054
|
+
if (crypto3 && data2.data.cipher) {
|
|
48055
|
+
const fields = await decryptPersonCipher(data2.data.cipher, crypto3.key);
|
|
48056
|
+
Object.assign(data2.data, fields, { cipher: null });
|
|
48057
|
+
}
|
|
48058
|
+
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
48059
|
+
}
|
|
48060
|
+
const data = await client.get("/api/persons");
|
|
48061
|
+
if (crypto3 && data.data) {
|
|
48062
|
+
for (const person of data.data) {
|
|
48063
|
+
const cipher = person.cipher;
|
|
48064
|
+
if (cipher) {
|
|
48065
|
+
try {
|
|
48066
|
+
const fields = await decryptPersonCipher(cipher, crypto3.key);
|
|
48067
|
+
Object.assign(person, fields, { cipher: null });
|
|
48068
|
+
} catch {}
|
|
48069
|
+
}
|
|
48070
|
+
}
|
|
48071
|
+
}
|
|
48072
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48073
|
+
});
|
|
48074
|
+
server.registerTool("delete_person", {
|
|
48075
|
+
description: "Delete a person. Returns 409 if any transactions still reference this person.",
|
|
48076
|
+
inputSchema: { id: exports_external.string().describe("Person UUID") }
|
|
48077
|
+
}, async ({ id }) => {
|
|
48078
|
+
const data = await client.delete(`/api/persons/${id}`);
|
|
48079
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48080
|
+
});
|
|
46690
48081
|
}
|
|
46691
|
-
|
|
46692
|
-
|
|
46693
|
-
|
|
46694
|
-
|
|
46695
|
-
|
|
46696
|
-
|
|
46697
|
-
|
|
46698
|
-
|
|
46699
|
-
|
|
48082
|
+
|
|
48083
|
+
// src/tools/profile.ts
|
|
48084
|
+
function registerProfileTools(server, client) {
|
|
48085
|
+
server.registerTool("get_profile", {
|
|
48086
|
+
description: "Get the current user's profile (name, email, owner).",
|
|
48087
|
+
inputSchema: {}
|
|
48088
|
+
}, async () => {
|
|
48089
|
+
const data = await client.get("/api/profile");
|
|
48090
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48091
|
+
});
|
|
48092
|
+
server.registerTool("update_profile", {
|
|
48093
|
+
description: "Update your display name.",
|
|
48094
|
+
inputSchema: {
|
|
48095
|
+
name: exports_external.string().min(1).describe("Display name")
|
|
46700
48096
|
}
|
|
46701
|
-
|
|
46702
|
-
|
|
46703
|
-
|
|
46704
|
-
|
|
48097
|
+
}, async (body) => {
|
|
48098
|
+
const data = await client.patch("/api/profile", body);
|
|
48099
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48100
|
+
});
|
|
46705
48101
|
}
|
|
46706
|
-
|
|
46707
|
-
|
|
46708
|
-
|
|
46709
|
-
|
|
46710
|
-
|
|
46711
|
-
|
|
46712
|
-
|
|
46713
|
-
|
|
46714
|
-
return {
|
|
46715
|
-
|
|
46716
|
-
|
|
46717
|
-
|
|
46718
|
-
|
|
46719
|
-
|
|
46720
|
-
|
|
48102
|
+
|
|
48103
|
+
// src/tools/push.ts
|
|
48104
|
+
function registerPushTools(server, client) {
|
|
48105
|
+
server.registerTool("list_push_subscriptions", {
|
|
48106
|
+
description: "List the household's registered web-push subscriptions (browser/device endpoints that receive alert notifications).",
|
|
48107
|
+
inputSchema: {}
|
|
48108
|
+
}, async () => {
|
|
48109
|
+
const data = await client.get("/api/push/subscriptions");
|
|
48110
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48111
|
+
});
|
|
48112
|
+
server.registerTool("send_test_push", {
|
|
48113
|
+
description: "Send a test web-push notification to all of the household's subscriptions. Use to verify push delivery is working.",
|
|
48114
|
+
inputSchema: {}
|
|
48115
|
+
}, async () => {
|
|
48116
|
+
const data = await client.post("/api/push/test");
|
|
48117
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48118
|
+
});
|
|
46721
48119
|
}
|
|
46722
|
-
|
|
46723
|
-
|
|
46724
|
-
|
|
46725
|
-
|
|
46726
|
-
|
|
46727
|
-
|
|
46728
|
-
|
|
46729
|
-
|
|
46730
|
-
|
|
46731
|
-
|
|
46732
|
-
|
|
46733
|
-
|
|
48120
|
+
|
|
48121
|
+
// src/tools/recurring.ts
|
|
48122
|
+
function registerRecurringTools(server, client) {
|
|
48123
|
+
server.registerTool("list_recurring", {
|
|
48124
|
+
description: "List recurring transactions (subscriptions, rent, salaries). These are auto-detected from transaction history; they cannot be added manually.",
|
|
48125
|
+
inputSchema: {}
|
|
48126
|
+
}, async () => {
|
|
48127
|
+
const data = await client.get("/api/recurring");
|
|
48128
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48129
|
+
});
|
|
48130
|
+
server.registerTool("sync_recurring", {
|
|
48131
|
+
description: "Auto-flag detected recurring merchants (isRecurring) from transaction history. Run after importing statements.",
|
|
48132
|
+
inputSchema: {}
|
|
48133
|
+
}, async () => {
|
|
48134
|
+
const data = await client.post("/api/recurring/sync");
|
|
48135
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48136
|
+
});
|
|
46734
48137
|
}
|
|
46735
|
-
|
|
46736
|
-
|
|
46737
|
-
|
|
46738
|
-
|
|
46739
|
-
|
|
46740
|
-
|
|
46741
|
-
|
|
46742
|
-
|
|
46743
|
-
for (let page = 0;page < MAX_PAGES; page++) {
|
|
46744
|
-
const params = { limit: 200 };
|
|
46745
|
-
if (cursor)
|
|
46746
|
-
params.cursor = cursor;
|
|
46747
|
-
const res = await client.get("/api/transactions", params);
|
|
46748
|
-
const rows = res.data ?? [];
|
|
46749
|
-
if (crypto3)
|
|
46750
|
-
await decryptTransactionFields(rows, crypto3.key);
|
|
46751
|
-
for (const r2 of rows) {
|
|
46752
|
-
if (!r2.merchantId && !r2.personId && !r2.isTransfer && !r2.isIgnored)
|
|
46753
|
-
candidates.push(r2);
|
|
46754
|
-
}
|
|
46755
|
-
if (!res.hasMore || !res.nextCursor || rows.length === 0)
|
|
46756
|
-
break;
|
|
46757
|
-
cursor = res.nextCursor;
|
|
46758
|
-
}
|
|
46759
|
-
const groups = new Map;
|
|
46760
|
-
for (const t2 of candidates) {
|
|
46761
|
-
const { name, upiId, paymentMethod } = parseNarration(t2.description);
|
|
46762
|
-
if (!name)
|
|
46763
|
-
continue;
|
|
46764
|
-
if (classifyEntity({ paymentMethod, extractedName: name }) !== "person")
|
|
46765
|
-
continue;
|
|
46766
|
-
const norm = normalizePersonName(name);
|
|
46767
|
-
if (!norm)
|
|
46768
|
-
continue;
|
|
46769
|
-
const g3 = groups.get(norm) ?? {
|
|
46770
|
-
displayName: name.trim(),
|
|
46771
|
-
upiHandles: new Set,
|
|
46772
|
-
txnIds: []
|
|
46773
|
-
};
|
|
46774
|
-
if (upiId)
|
|
46775
|
-
g3.upiHandles.add(upiId);
|
|
46776
|
-
g3.txnIds.push(t2.id);
|
|
46777
|
-
groups.set(norm, g3);
|
|
46778
|
-
}
|
|
46779
|
-
const plan = [];
|
|
46780
|
-
const newGroups = [];
|
|
46781
|
-
let reused = 0;
|
|
46782
|
-
for (const [norm, g3] of groups) {
|
|
46783
|
-
const upiHandles = [...g3.upiHandles];
|
|
46784
|
-
const isReuse = personIdByNorm.has(norm);
|
|
46785
|
-
plan.push({
|
|
46786
|
-
name: g3.displayName,
|
|
46787
|
-
normalized: norm,
|
|
46788
|
-
txnCount: g3.txnIds.length,
|
|
46789
|
-
upiHandles,
|
|
46790
|
-
action: isReuse ? "reuse" : "create"
|
|
46791
|
-
});
|
|
46792
|
-
if (isReuse)
|
|
46793
|
-
reused++;
|
|
46794
|
-
else
|
|
46795
|
-
newGroups.push({ norm, displayName: g3.displayName, upiHandles });
|
|
46796
|
-
}
|
|
46797
|
-
if (!opts.dryRun && newGroups.length > 0) {
|
|
46798
|
-
const bodies = await Promise.all(newGroups.map(async ({ displayName, upiHandles }) => {
|
|
46799
|
-
const fields = {
|
|
46800
|
-
name: displayName,
|
|
46801
|
-
upiHandles,
|
|
46802
|
-
phoneNumbers: [],
|
|
46803
|
-
relationship: null,
|
|
46804
|
-
notes: null
|
|
46805
|
-
};
|
|
46806
|
-
return crypto3 ? await encryptPersonCipher(fields, crypto3.key) : { name: displayName, upiHandles, phoneNumbers: [], relationship: null, notes: null };
|
|
46807
|
-
}));
|
|
46808
|
-
const res = await client.post("/api/persons/bulk", {
|
|
46809
|
-
persons: bodies
|
|
46810
|
-
});
|
|
46811
|
-
res.data.forEach((p2, i2) => {
|
|
46812
|
-
personIdByNorm.set(newGroups[i2].norm, p2.id);
|
|
46813
|
-
});
|
|
46814
|
-
}
|
|
46815
|
-
const created = newGroups.length;
|
|
46816
|
-
const links = [];
|
|
46817
|
-
if (!opts.dryRun) {
|
|
46818
|
-
for (const [norm, g3] of groups) {
|
|
46819
|
-
const personId = personIdByNorm.get(norm);
|
|
46820
|
-
if (!personId)
|
|
46821
|
-
continue;
|
|
46822
|
-
for (const txnId of g3.txnIds)
|
|
46823
|
-
links.push({ transactionId: txnId, personId });
|
|
48138
|
+
|
|
48139
|
+
// src/tools/reports.ts
|
|
48140
|
+
function registerReportTools(server, client) {
|
|
48141
|
+
server.registerTool("generate_monthly_report", {
|
|
48142
|
+
description: "Generate a detailed monthly financial report. Computes income, expenses, savings rate, top categories, and insights. Persists to DB.",
|
|
48143
|
+
inputSchema: {
|
|
48144
|
+
month: exports_external.number().min(1).max(12),
|
|
48145
|
+
year: exports_external.number().min(2020).max(2100)
|
|
46824
48146
|
}
|
|
46825
|
-
}
|
|
46826
|
-
|
|
46827
|
-
|
|
46828
|
-
|
|
46829
|
-
|
|
48147
|
+
}, async (body) => {
|
|
48148
|
+
const data = await client.post("/api/reports/generate", body);
|
|
48149
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48150
|
+
});
|
|
48151
|
+
server.registerTool("get_reports", {
|
|
48152
|
+
description: "Monthly financial reports for the last N months — income, expenses, savings rate, net worth.",
|
|
48153
|
+
inputSchema: {
|
|
48154
|
+
months: exports_external.number().optional().describe("Number of months (default: 6)")
|
|
46830
48155
|
}
|
|
46831
|
-
}
|
|
46832
|
-
|
|
48156
|
+
}, async (params) => {
|
|
48157
|
+
const data = await client.get("/api/reports", params.months ? { months: params.months } : undefined);
|
|
48158
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48159
|
+
});
|
|
46833
48160
|
}
|
|
46834
48161
|
|
|
48162
|
+
// src/lib/statement-import.ts
|
|
48163
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
48164
|
+
import { basename } from "node:path";
|
|
48165
|
+
|
|
46835
48166
|
// src/tools/transactions.ts
|
|
48167
|
+
import { readFile } from "node:fs/promises";
|
|
46836
48168
|
function dedupeKey(date4, description, amount, type) {
|
|
46837
48169
|
return `${date4}|${description}|${amount.toFixed(2)}|${type}`;
|
|
46838
48170
|
}
|
|
@@ -46940,8 +48272,23 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46940
48272
|
owner: exports_external.string().optional(),
|
|
46941
48273
|
categorySlug: exports_external.string().optional(),
|
|
46942
48274
|
categoryType: exports_external.enum(["needs", "wants", "investments", "income", "transfer"]).optional(),
|
|
46943
|
-
source: exports_external.string().optional(),
|
|
46944
48275
|
type: exports_external.enum(["debit", "credit"]).optional(),
|
|
48276
|
+
source: exports_external.enum([
|
|
48277
|
+
"bank_statement",
|
|
48278
|
+
"cc_statement",
|
|
48279
|
+
"zomato",
|
|
48280
|
+
"swiggy",
|
|
48281
|
+
"blinkit",
|
|
48282
|
+
"amazon",
|
|
48283
|
+
"zerodha_kite",
|
|
48284
|
+
"zerodha_coin",
|
|
48285
|
+
"groww",
|
|
48286
|
+
"cas_statement",
|
|
48287
|
+
"manual",
|
|
48288
|
+
"telegram",
|
|
48289
|
+
"wisprflow",
|
|
48290
|
+
"ai_import"
|
|
48291
|
+
]).optional().describe("Filter by ingestion source"),
|
|
46945
48292
|
amountMin: exports_external.number().optional(),
|
|
46946
48293
|
amountMax: exports_external.number().optional(),
|
|
46947
48294
|
uncategorized: exports_external.boolean().optional().describe("Only transactions with no category (server-side filter)"),
|
|
@@ -47105,6 +48452,12 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
47105
48452
|
resolution: exports_external.enum(["keep", "ignore"]).optional().describe("[resolve] 'ignore' excludes the duplicates, 'keep' unflags them")
|
|
47106
48453
|
}
|
|
47107
48454
|
}, async ({ action, transactionId, ids, resolution }) => {
|
|
48455
|
+
if (action === "flag" && !transactionId) {
|
|
48456
|
+
throw new Error("transactionId is required for action 'flag'");
|
|
48457
|
+
}
|
|
48458
|
+
if (action === "resolve" && (!ids || ids.length === 0 || !resolution)) {
|
|
48459
|
+
throw new Error("ids and resolution are required for action 'resolve'");
|
|
48460
|
+
}
|
|
47108
48461
|
if (action === "flag") {
|
|
47109
48462
|
const data2 = await client.patch(`/api/transactions/${transactionId}`, {
|
|
47110
48463
|
isDuplicate: true,
|
|
@@ -47309,16 +48662,152 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
47309
48662
|
});
|
|
47310
48663
|
}
|
|
47311
48664
|
|
|
48665
|
+
// src/lib/statement-import.ts
|
|
48666
|
+
var realParsers = {
|
|
48667
|
+
"hdfc-savings": parseHdfcSavingsPdf,
|
|
48668
|
+
"hdfc-card": parseHdfcCreditCardPdf,
|
|
48669
|
+
amex: parseAmexCreditCardPdf,
|
|
48670
|
+
"sbi-savings": parseSbiSavingsPdf
|
|
48671
|
+
};
|
|
48672
|
+
async function readFileAsArrayBuffer(path) {
|
|
48673
|
+
const b2 = await readFile2(path);
|
|
48674
|
+
return b2.buffer.slice(b2.byteOffset, b2.byteOffset + b2.byteLength);
|
|
48675
|
+
}
|
|
48676
|
+
function resolveAccount(accounts, query) {
|
|
48677
|
+
const q2 = query.trim().toLowerCase();
|
|
48678
|
+
const byId = accounts.find((a2) => a2.id === query);
|
|
48679
|
+
if (byId)
|
|
48680
|
+
return byId;
|
|
48681
|
+
let hits = accounts.filter((a2) => a2.name.toLowerCase() === q2);
|
|
48682
|
+
if (hits.length === 0) {
|
|
48683
|
+
hits = accounts.filter((a2) => a2.name.toLowerCase().includes(q2) || a2.last4 !== null && a2.last4 === q2);
|
|
48684
|
+
}
|
|
48685
|
+
if (hits.length === 1)
|
|
48686
|
+
return hits[0];
|
|
48687
|
+
const names = (hits.length ? hits : accounts).map((a2) => a2.name).join(", ");
|
|
48688
|
+
throw new Error(hits.length === 0 ? `No account matches "${query}". Accounts: ${names || "(none yet)"}. ` + "To add it, call upsert_bank_account (name, bankName, type: savings|current|credit_card|wallet, last4, owner), then retry." : `"${query}" matches several accounts: ${names}. Use the exact name or id.`);
|
|
48689
|
+
}
|
|
48690
|
+
function pickFormat(account, override) {
|
|
48691
|
+
if (override)
|
|
48692
|
+
return override;
|
|
48693
|
+
const bank = `${account.bankName ?? ""} ${account.name}`.toLowerCase();
|
|
48694
|
+
const card = account.type === "credit_card";
|
|
48695
|
+
if (bank.includes("amex") || bank.includes("american express"))
|
|
48696
|
+
return "amex";
|
|
48697
|
+
if (bank.includes("hdfc"))
|
|
48698
|
+
return card ? "hdfc-card" : "hdfc-savings";
|
|
48699
|
+
if (bank.includes("sbi") && !card)
|
|
48700
|
+
return "sbi-savings";
|
|
48701
|
+
throw new Error(`No PDF parser for "${account.name}" (${account.bankName ?? "unknown bank"}, ${account.type}) yet. ` + "Supported: HDFC savings + cards, Amex, SBI savings.");
|
|
48702
|
+
}
|
|
48703
|
+
var isPasswordError = (e2) => e2 instanceof Error && /password/i.test(`${e2.name} ${e2.message}`);
|
|
48704
|
+
var sum = (rows) => Math.round(rows.reduce((n2, r2) => n2 + r2.amount, 0));
|
|
48705
|
+
async function runStatementImport(deps, args) {
|
|
48706
|
+
const { client, crypto: crypto3 } = deps;
|
|
48707
|
+
const accounts = (await client.get("/api/accounts")).data ?? [];
|
|
48708
|
+
const account = resolveAccount(accounts, args.account);
|
|
48709
|
+
const format = pickFormat(account, args.format);
|
|
48710
|
+
let statement;
|
|
48711
|
+
try {
|
|
48712
|
+
statement = await deps.parsers[format](await deps.readFile(args.filePath), args.password);
|
|
48713
|
+
} catch (e2) {
|
|
48714
|
+
if (isPasswordError(e2)) {
|
|
48715
|
+
throw new Error(`${basename(args.filePath)} is password-protected and the password was ${args.password ? "wrong" : "not given"}.`);
|
|
48716
|
+
}
|
|
48717
|
+
throw e2;
|
|
48718
|
+
}
|
|
48719
|
+
const txns = statement.transactions.filter((t2) => t2.amount > 0);
|
|
48720
|
+
if (txns.length === 0) {
|
|
48721
|
+
throw new Error(`${basename(args.filePath)} parsed to 0 transactions (format ${format}). If it is a statement with real activity, the layout may not be supported.`);
|
|
48722
|
+
}
|
|
48723
|
+
const dates = txns.map((t2) => t2.date).sort();
|
|
48724
|
+
const ctx = await client.get("/api/transactions/reconciliation-context", { accountId: account.id, startDate: dates[0], endDate: dates[dates.length - 1] });
|
|
48725
|
+
const existing = ctx.data ?? [];
|
|
48726
|
+
if (crypto3)
|
|
48727
|
+
await decryptTransactionFields(existing, crypto3.key);
|
|
48728
|
+
const present = existing.map((r2) => ({ ...r2, isDuplicate: false, isIgnored: false }));
|
|
48729
|
+
const result = reconcile(present, { ...statement, transactions: txns });
|
|
48730
|
+
const fresh = result.unmatched;
|
|
48731
|
+
const debits = fresh.filter((t2) => t2.type === "debit");
|
|
48732
|
+
const credits = fresh.filter((t2) => t2.type === "credit");
|
|
48733
|
+
const summary = {
|
|
48734
|
+
account: account.name,
|
|
48735
|
+
file: basename(args.filePath),
|
|
48736
|
+
format,
|
|
48737
|
+
period: { from: dates[0], to: dates[dates.length - 1] },
|
|
48738
|
+
parsed: txns.length,
|
|
48739
|
+
alreadyInPaisa: result.matched.length,
|
|
48740
|
+
conflicts: result.conflicts.slice(0, 5).map((c2) => ({
|
|
48741
|
+
date: c2.incoming.date,
|
|
48742
|
+
amount: c2.incoming.amount,
|
|
48743
|
+
reason: c2.reason
|
|
48744
|
+
})),
|
|
48745
|
+
conflictCount: result.conflicts.length,
|
|
48746
|
+
new: {
|
|
48747
|
+
n: fresh.length,
|
|
48748
|
+
debits: { n: debits.length, total: sum(debits) },
|
|
48749
|
+
credits: { n: credits.length, total: sum(credits) }
|
|
48750
|
+
}
|
|
48751
|
+
};
|
|
48752
|
+
if (!args.confirm) {
|
|
48753
|
+
return {
|
|
48754
|
+
...summary,
|
|
48755
|
+
dryRun: true,
|
|
48756
|
+
sample: fresh.slice(0, 8).map((t2) => ({
|
|
48757
|
+
date: t2.date,
|
|
48758
|
+
amt: t2.amount,
|
|
48759
|
+
t: t2.type === "credit" ? "cr" : "dr",
|
|
48760
|
+
desc: t2.description.slice(0, 50)
|
|
48761
|
+
})),
|
|
48762
|
+
next: fresh.length === 0 ? "Nothing new — this statement is already fully in Paisa." : `Call again with confirm:true to import the ${fresh.length} new rows${result.conflicts.length ? " (conflicts are skipped)" : ""}.`
|
|
48763
|
+
};
|
|
48764
|
+
}
|
|
48765
|
+
if (fresh.length === 0)
|
|
48766
|
+
return { ...summary, dryRun: false, imported: 0 };
|
|
48767
|
+
const savings = account.type !== "credit_card";
|
|
48768
|
+
const imported = await importTransactions(client, crypto3, {
|
|
48769
|
+
accountId: account.id,
|
|
48770
|
+
source: savings ? "bank_statement" : "cc_statement",
|
|
48771
|
+
granularity: savings ? 1 : 2,
|
|
48772
|
+
txns: fresh.map((t2) => ({
|
|
48773
|
+
date: t2.date,
|
|
48774
|
+
amount: t2.amount,
|
|
48775
|
+
type: t2.type,
|
|
48776
|
+
description: t2.description,
|
|
48777
|
+
referenceNumber: t2.referenceNumber ?? undefined
|
|
48778
|
+
}))
|
|
48779
|
+
});
|
|
48780
|
+
const rescan = await runRescan(client, crypto3);
|
|
48781
|
+
return { ...summary, dryRun: false, ...imported, rescan };
|
|
48782
|
+
}
|
|
48783
|
+
|
|
48784
|
+
// src/tools/statement-import.ts
|
|
48785
|
+
function registerStatementImportTools(server, client, crypto3) {
|
|
48786
|
+
server.registerTool("import_statement_pdf", {
|
|
48787
|
+
description: "Import a bank/credit-card statement PDF from a local file. TWO-PHASE: without `confirm` it only parses and reconciles against what Paisa already has and returns a summary " + "(rows already in Paisa, conflicts, NEW rows with totals) — nothing is written. Call again with confirm:true to import just the new rows, then run the categorize rescan. " + "Rows already in Paisa are never re-imported; conflicting rows (same date/name, different amount) are reported, never auto-imported. " + "Supported: HDFC savings + credit cards, Amex, SBI savings. Encrypted in private mode; reads the file locally.",
|
|
48788
|
+
inputSchema: {
|
|
48789
|
+
filePath: exports_external.string().describe("Absolute path to the statement PDF"),
|
|
48790
|
+
account: exports_external.string().describe("Paisa account name (e.g. 'HDFC Swiggy'), last4, or id — see list_bank_accounts. If the account doesn't exist yet, create it first with upsert_bank_account"),
|
|
48791
|
+
password: exports_external.string().optional().describe("PDF password, if the file is protected"),
|
|
48792
|
+
format: exports_external.enum(["hdfc-savings", "hdfc-card", "amex", "sbi-savings"]).optional().describe("Override the parser (default: chosen from the account's bank and type)"),
|
|
48793
|
+
confirm: exports_external.boolean().optional().describe("false/omitted = dry run (nothing written); true = import the new rows")
|
|
48794
|
+
}
|
|
48795
|
+
}, async (args) => {
|
|
48796
|
+
const result = await runStatementImport({ client, crypto: crypto3, readFile: readFileAsArrayBuffer, parsers: realParsers }, args);
|
|
48797
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
48798
|
+
});
|
|
48799
|
+
}
|
|
48800
|
+
|
|
47312
48801
|
// src/tools/upcoming.ts
|
|
47313
48802
|
function registerUpcomingTools(server, client) {
|
|
47314
48803
|
server.registerTool("get_upcoming", {
|
|
47315
48804
|
description: "Get upcoming payments (EMIs, SIPs, insurance renewals, CC billing) due in the next N days.",
|
|
47316
48805
|
inputSchema: {
|
|
47317
|
-
days: exports_external.number().optional().describe("Look-ahead window in days (default
|
|
48806
|
+
days: exports_external.number().int().min(1).max(365).optional().describe("Look-ahead window in days (1-365, default 60)")
|
|
47318
48807
|
}
|
|
47319
|
-
}, async (
|
|
47320
|
-
const
|
|
47321
|
-
return { content: [{ type: "text", text: JSON.stringify(
|
|
48808
|
+
}, async ({ days }) => {
|
|
48809
|
+
const res = await client.get("/api/upcoming", { days });
|
|
48810
|
+
return { content: [{ type: "text", text: JSON.stringify(res) }] };
|
|
47322
48811
|
});
|
|
47323
48812
|
}
|
|
47324
48813
|
|
|
@@ -47333,6 +48822,79 @@ function registerWaitlistTools(server, client) {
|
|
|
47333
48822
|
});
|
|
47334
48823
|
}
|
|
47335
48824
|
|
|
48825
|
+
// src/registrations.ts
|
|
48826
|
+
var REGISTRATIONS = [
|
|
48827
|
+
{ title: "Transactions", register: registerTransactionTools },
|
|
48828
|
+
{ title: "Statement import", register: registerStatementImportTools },
|
|
48829
|
+
{ title: "Jev auto-categorize", register: registerJevTools },
|
|
48830
|
+
{ title: "Merchants", register: registerMerchantTools },
|
|
48831
|
+
{ title: "Persons", register: registerPersonTools },
|
|
48832
|
+
{ title: "Categories", register: registerCategoryTools },
|
|
48833
|
+
{ title: "Analytics", register: registerAnalyticsTools },
|
|
48834
|
+
{ title: "Bank accounts", register: registerAccountTools },
|
|
48835
|
+
{ title: "Goals & EMIs", register: registerGoalTools },
|
|
48836
|
+
{ title: "Budgets", register: registerBudgetTools },
|
|
48837
|
+
{ title: "Upcoming", register: registerUpcomingTools },
|
|
48838
|
+
{ title: "Dashboard", register: registerDashboardTools },
|
|
48839
|
+
{ title: "Recurring & subscriptions", register: registerRecurringTools },
|
|
48840
|
+
{ title: "Alerts", register: registerAlertTools },
|
|
48841
|
+
{ title: "Alert rules", register: registerAlertRuleTools },
|
|
48842
|
+
{ title: "Debts", register: registerDebtTools },
|
|
48843
|
+
{ title: "Reports", register: registerReportTools },
|
|
48844
|
+
{ title: "Export", register: registerExportTools },
|
|
48845
|
+
{ title: "Heatmap", register: registerHeatmapTools },
|
|
48846
|
+
{ title: "Household", register: registerHouseholdTools },
|
|
48847
|
+
{ title: "Household learning", register: registerHouseholdLearningTools },
|
|
48848
|
+
{ title: "Net worth", register: registerNetWorthTools },
|
|
48849
|
+
{ title: "Investments", register: registerInvestmentTools },
|
|
48850
|
+
{ title: "Profile", register: registerProfileTools },
|
|
48851
|
+
{ title: "Encryption & privacy", register: registerAuthCryptoTools },
|
|
48852
|
+
{ title: "Waitlist", register: registerWaitlistTools },
|
|
48853
|
+
{ title: "Push notifications", register: registerPushTools }
|
|
48854
|
+
];
|
|
48855
|
+
// package.json
|
|
48856
|
+
var package_default = {
|
|
48857
|
+
name: "paisa-mcp",
|
|
48858
|
+
version: "0.0.22",
|
|
48859
|
+
description: "Paisa MCP server — personal finance tools for AI assistants",
|
|
48860
|
+
bin: {
|
|
48861
|
+
"paisa-mcp": "dist/index.js"
|
|
48862
|
+
},
|
|
48863
|
+
type: "module",
|
|
48864
|
+
files: [
|
|
48865
|
+
"dist"
|
|
48866
|
+
],
|
|
48867
|
+
scripts: {
|
|
48868
|
+
build: 'bun build src/index.ts --outdir dist --target node --format esm --sourcemap --external pdfjs-dist --external "pdfjs-dist/*"',
|
|
48869
|
+
"gen:tools-doc": "bun src/tools-doc.ts",
|
|
48870
|
+
start: "node dist/index.js",
|
|
48871
|
+
test: "bun test",
|
|
48872
|
+
typecheck: "tsc --noEmit"
|
|
48873
|
+
},
|
|
48874
|
+
dependencies: {
|
|
48875
|
+
"pdfjs-dist": "^5.6.205"
|
|
48876
|
+
},
|
|
48877
|
+
devDependencies: {
|
|
48878
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
48879
|
+
"@paisa/parsers": "workspace:*",
|
|
48880
|
+
"@paisa/reconciliation": "workspace:*",
|
|
48881
|
+
"@paisa/types": "workspace:*",
|
|
48882
|
+
"@scure/bip39": "^2.2.0",
|
|
48883
|
+
"@types/bun": "^1.3.14",
|
|
48884
|
+
"@typesafe-ai/sdk": "^0.6.0",
|
|
48885
|
+
axios: "^1.16.1",
|
|
48886
|
+
"hash-wasm": "^4.12.0",
|
|
48887
|
+
typescript: "^5.8.3",
|
|
48888
|
+
zod: "^3.25.17"
|
|
48889
|
+
},
|
|
48890
|
+
engines: {
|
|
48891
|
+
node: ">=20.0.0"
|
|
48892
|
+
}
|
|
48893
|
+
};
|
|
48894
|
+
|
|
48895
|
+
// src/version.ts
|
|
48896
|
+
var VERSION3 = package_default.version;
|
|
48897
|
+
|
|
47336
48898
|
// src/index.ts
|
|
47337
48899
|
async function main() {
|
|
47338
48900
|
const apiUrl = process.env.PAISA_API_URL;
|
|
@@ -47343,56 +48905,16 @@ async function main() {
|
|
|
47343
48905
|
process.exit(1);
|
|
47344
48906
|
}
|
|
47345
48907
|
const client = new PaisaApiClient(apiUrl, apiToken);
|
|
47346
|
-
|
|
47347
|
-
|
|
47348
|
-
const userId = process.env.PAISA_USER_ID;
|
|
47349
|
-
if (pin) {
|
|
47350
|
-
if (!userId) {
|
|
47351
|
-
console.error("Error: PAISA_USER_ID is required when PAISA_PIN is set.");
|
|
47352
|
-
process.exit(1);
|
|
47353
|
-
}
|
|
47354
|
-
const cryptoMeta = await client.get("/api/auth-crypto").catch(() => null);
|
|
47355
|
-
if (cryptoMeta && (cryptoMeta.kdfVersion ?? 1) >= 2 && cryptoMeta.kdfSalt && cryptoMeta.wrappedDek) {
|
|
47356
|
-
const kek = await deriveKEK(pin, cryptoMeta.kdfSalt);
|
|
47357
|
-
const dek = await unwrapDEK(cryptoMeta.wrappedDek, kek);
|
|
47358
|
-
cryptoCtx = { key: dek };
|
|
47359
|
-
} else {
|
|
47360
|
-
const key = await deriveKey(userId, pin, userId);
|
|
47361
|
-
cryptoCtx = { key };
|
|
47362
|
-
}
|
|
48908
|
+
const cryptoCtx = await initCrypto(client, process.env.PAISA_PIN, process.env.PAISA_USER_ID);
|
|
48909
|
+
if (cryptoCtx)
|
|
47363
48910
|
console.error("Paisa MCP: private mode — encryption active");
|
|
47364
|
-
}
|
|
47365
48911
|
const crypto3 = cryptoCtx;
|
|
47366
48912
|
const server = new McpServer({
|
|
47367
48913
|
name: "paisa",
|
|
47368
|
-
version:
|
|
47369
|
-
});
|
|
47370
|
-
|
|
47371
|
-
|
|
47372
|
-
registerMerchantTools(server, client, crypto3);
|
|
47373
|
-
registerPersonTools(server, client, crypto3);
|
|
47374
|
-
registerCategoryTools(server, client);
|
|
47375
|
-
registerAnalyticsTools(server, client, crypto3);
|
|
47376
|
-
registerAccountTools(server, client);
|
|
47377
|
-
registerGoalTools(server, client);
|
|
47378
|
-
registerBudgetTools(server, client);
|
|
47379
|
-
registerUpcomingTools(server, client);
|
|
47380
|
-
registerDashboardTools(server, client, crypto3);
|
|
47381
|
-
registerRecurringTools(server, client);
|
|
47382
|
-
registerAlertTools(server, client);
|
|
47383
|
-
registerAlertRuleTools(server, client);
|
|
47384
|
-
registerDebtTools(server, client);
|
|
47385
|
-
registerReportTools(server, client);
|
|
47386
|
-
registerExportTools(server, client, crypto3);
|
|
47387
|
-
registerHeatmapTools(server, client);
|
|
47388
|
-
registerHouseholdTools(server, client);
|
|
47389
|
-
registerHouseholdLearningTools(server, client, crypto3);
|
|
47390
|
-
registerNetWorthTools(server, client);
|
|
47391
|
-
registerInvestmentTools(server, client);
|
|
47392
|
-
registerProfileTools(server, client);
|
|
47393
|
-
registerAuthCryptoTools(server, client);
|
|
47394
|
-
registerWaitlistTools(server, client);
|
|
47395
|
-
registerPushTools(server, client);
|
|
48914
|
+
version: VERSION3
|
|
48915
|
+
});
|
|
48916
|
+
for (const { register } of REGISTRATIONS)
|
|
48917
|
+
register(server, client, crypto3);
|
|
47396
48918
|
const transport = new StdioServerTransport;
|
|
47397
48919
|
await server.connect(transport);
|
|
47398
48920
|
console.error("Paisa MCP server running on stdio");
|
|
@@ -47402,5 +48924,5 @@ main().catch((err) => {
|
|
|
47402
48924
|
process.exit(1);
|
|
47403
48925
|
});
|
|
47404
48926
|
|
|
47405
|
-
//# debugId=
|
|
48927
|
+
//# debugId=BC4AFE8852AA4C8E64756E2164756E21
|
|
47406
48928
|
//# sourceMappingURL=index.js.map
|