paisa-mcp 0.0.20 → 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 +3339 -736
- package/dist/index.js.map +43 -22
- package/package.json +11 -11
package/dist/index.js
CHANGED
|
@@ -28092,7 +28092,7 @@ function finalize(ctx, schema) {
|
|
|
28092
28092
|
result.$schema = "http://json-schema.org/draft-07/schema#";
|
|
28093
28093
|
} else if (ctx.target === "draft-04") {
|
|
28094
28094
|
result.$schema = "http://json-schema.org/draft-04/schema#";
|
|
28095
|
-
} else if (ctx.target === "openapi-3.0") {}
|
|
28095
|
+
} else if (ctx.target === "openapi-3.0") {}
|
|
28096
28096
|
if (ctx.external?.uri) {
|
|
28097
28097
|
const id = ctx.external.registry.get(schema)?.id;
|
|
28098
28098
|
if (!id)
|
|
@@ -28340,7 +28340,7 @@ var literalProcessor = (schema, ctx, json, _params) => {
|
|
|
28340
28340
|
if (val === undefined) {
|
|
28341
28341
|
if (ctx.unrepresentable === "throw") {
|
|
28342
28342
|
throw new Error("Literal `undefined` cannot be represented in JSON Schema");
|
|
28343
|
-
}
|
|
28343
|
+
}
|
|
28344
28344
|
} else if (typeof val === "bigint") {
|
|
28345
28345
|
if (ctx.unrepresentable === "throw") {
|
|
28346
28346
|
throw new Error("BigInt literals cannot be represented in JSON Schema");
|
|
@@ -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
|
|
@@ -42773,7 +42846,7 @@ function registerHeatmapTools(server, client) {
|
|
|
42773
42846
|
});
|
|
42774
42847
|
}
|
|
42775
42848
|
|
|
42776
|
-
// src/
|
|
42849
|
+
// src/lib/learning-bag.ts
|
|
42777
42850
|
var EMPTY_BAG = {
|
|
42778
42851
|
merchantAliases: [],
|
|
42779
42852
|
personAliases: [],
|
|
@@ -42784,15 +42857,17 @@ 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) {
|
|
42790
|
-
const res = await client.put("/api/household-learning", {
|
|
42791
|
-
bag,
|
|
42792
|
-
expectedVersion
|
|
42793
|
-
});
|
|
42866
|
+
const res = await client.put("/api/household-learning", { bag, expectedVersion });
|
|
42794
42867
|
return { version: res.data.version };
|
|
42795
42868
|
}
|
|
42869
|
+
|
|
42870
|
+
// src/tools/household-learning.ts
|
|
42796
42871
|
function registerHouseholdLearningTools(server, client, _crypto) {
|
|
42797
42872
|
server.registerTool("list_learning", {
|
|
42798
42873
|
description: "List a slice of what the user has taught the categorizer for this household: " + "merchant->category rules, person->category rules, merchant aliases, or person aliases.",
|
|
@@ -42887,12 +42962,24 @@ function registerHouseholdTools(server, client) {
|
|
|
42887
42962
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42888
42963
|
});
|
|
42889
42964
|
server.registerTool("create_invite", {
|
|
42890
|
-
description: "
|
|
42891
|
-
inputSchema: {
|
|
42892
|
-
|
|
42893
|
-
}
|
|
42894
|
-
|
|
42895
|
-
|
|
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}`);
|
|
42896
42983
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42897
42984
|
});
|
|
42898
42985
|
}
|
|
@@ -42900,20 +42987,26 @@ function registerHouseholdTools(server, client) {
|
|
|
42900
42987
|
// src/tools/investments.ts
|
|
42901
42988
|
function registerInvestmentTools(server, client) {
|
|
42902
42989
|
server.registerTool("list_investments", {
|
|
42903
|
-
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).",
|
|
42904
42991
|
inputSchema: {
|
|
42905
|
-
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'")
|
|
42906
42994
|
}
|
|
42907
|
-
}, async ({ type }) => {
|
|
42908
|
-
|
|
42909
|
-
|
|
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");
|
|
42910
43003
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
42911
43004
|
});
|
|
42912
43005
|
server.registerTool("upsert_investment", {
|
|
42913
|
-
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).",
|
|
42914
43007
|
inputSchema: {
|
|
42915
43008
|
type: exports_external.enum(["sip", "fixed_deposit", "holding"]).describe("Investment type"),
|
|
42916
|
-
id: exports_external.string().optional().describe("sip:
|
|
43009
|
+
id: exports_external.string().optional().describe("sip & fixed_deposit: UUID — omit to add, pass to update"),
|
|
42917
43010
|
name: exports_external.string().optional().describe('sip: fund name (e.g. "Parag Parikh Flexi Cap")'),
|
|
42918
43011
|
startDate: exports_external.string().optional().describe("sip & fixed_deposit: YYYY-MM-DD"),
|
|
42919
43012
|
sipDay: exports_external.number().optional().describe("sip: day of month the SIP debits"),
|
|
@@ -42926,7 +43019,7 @@ function registerInvestmentTools(server, client) {
|
|
|
42926
43019
|
maturityDate: exports_external.string().optional().describe("fixed_deposit: YYYY-MM-DD"),
|
|
42927
43020
|
notes: exports_external.string().optional().describe("fixed_deposit: notes"),
|
|
42928
43021
|
holdingType: exports_external.enum(["mutual_fund", "stock", "gold", "ppf", "nps", "other"]).optional().describe("holding: asset type"),
|
|
42929
|
-
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'"),
|
|
42930
43023
|
symbol: exports_external.string().optional().describe("holding: ticker symbol (optional)"),
|
|
42931
43024
|
isin: exports_external.string().optional().describe("holding: ISIN (optional)"),
|
|
42932
43025
|
units: exports_external.string().optional().describe("holding: units held (optional)"),
|
|
@@ -42939,15 +43032,15 @@ function registerInvestmentTools(server, client) {
|
|
|
42939
43032
|
if (type === "holding") {
|
|
42940
43033
|
const { name, holdingType, owner: owner2, platform, symbol, isin, units, investedAmount } = fields;
|
|
42941
43034
|
const { currentValue } = fields;
|
|
42942
|
-
const
|
|
43035
|
+
const missing = [
|
|
42943
43036
|
["name", name],
|
|
42944
43037
|
["holdingType", holdingType],
|
|
42945
43038
|
["owner", owner2],
|
|
42946
43039
|
["investedAmount", investedAmount],
|
|
42947
43040
|
["currentValue", currentValue]
|
|
42948
43041
|
].filter(([, v]) => v === undefined).map(([k]) => k);
|
|
42949
|
-
if (
|
|
42950
|
-
throw new Error(`Missing required fields for holding: ${
|
|
43042
|
+
if (missing.length > 0) {
|
|
43043
|
+
throw new Error(`Missing required fields for holding: ${missing.join(", ")}`);
|
|
42951
43044
|
}
|
|
42952
43045
|
const data2 = await client.post("/api/investments/holdings", {
|
|
42953
43046
|
name,
|
|
@@ -42964,7 +43057,7 @@ function registerInvestmentTools(server, client) {
|
|
|
42964
43057
|
}
|
|
42965
43058
|
if (type === "sip") {
|
|
42966
43059
|
const {
|
|
42967
|
-
id,
|
|
43060
|
+
id: id2,
|
|
42968
43061
|
name,
|
|
42969
43062
|
amount: amount2,
|
|
42970
43063
|
startDate: startDate2,
|
|
@@ -42973,362 +43066,705 @@ function registerInvestmentTools(server, client) {
|
|
|
42973
43066
|
folioNumber,
|
|
42974
43067
|
category,
|
|
42975
43068
|
status,
|
|
42976
|
-
endDate
|
|
43069
|
+
endDate,
|
|
43070
|
+
platform
|
|
42977
43071
|
} = fields;
|
|
42978
|
-
if (!
|
|
42979
|
-
const
|
|
43072
|
+
if (!id2) {
|
|
43073
|
+
const missing = [
|
|
42980
43074
|
["name", name],
|
|
42981
43075
|
["amount", amount2],
|
|
42982
43076
|
["startDate", startDate2],
|
|
42983
43077
|
["sipDay", sipDay],
|
|
42984
43078
|
["owner", owner2]
|
|
42985
43079
|
].filter(([, v]) => v === undefined).map(([k]) => k);
|
|
42986
|
-
if (
|
|
42987
|
-
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(", ")}`);
|
|
42988
43082
|
}
|
|
42989
43083
|
}
|
|
42990
43084
|
const sipFields = {
|
|
42991
43085
|
name,
|
|
42992
43086
|
amount: amount2,
|
|
42993
43087
|
startDate: startDate2,
|
|
42994
|
-
sipDay,
|
|
43088
|
+
sipDate: sipDay,
|
|
42995
43089
|
owner: owner2,
|
|
42996
43090
|
folioNumber,
|
|
42997
43091
|
category,
|
|
42998
43092
|
status,
|
|
42999
|
-
endDate
|
|
43093
|
+
endDate,
|
|
43094
|
+
platform: id2 ? platform : platform ?? "manual"
|
|
43000
43095
|
};
|
|
43001
|
-
const data2 =
|
|
43096
|
+
const data2 = id2 ? await client.patch(`/api/investments/sips/${id2}`, sipFields) : await client.post("/api/investments/sips", sipFields);
|
|
43002
43097
|
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
43003
43098
|
}
|
|
43004
|
-
const { bankName, amount, interestRate, startDate, maturityDate, owner, notes } = fields;
|
|
43005
|
-
|
|
43006
|
-
[
|
|
43007
|
-
|
|
43008
|
-
|
|
43009
|
-
|
|
43010
|
-
|
|
43011
|
-
|
|
43012
|
-
|
|
43013
|
-
|
|
43014
|
-
|
|
43015
|
-
|
|
43016
|
-
|
|
43017
|
-
|
|
43018
|
-
|
|
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,
|
|
43019
43116
|
interestRate,
|
|
43020
43117
|
startDate,
|
|
43021
43118
|
maturityDate,
|
|
43022
43119
|
owner,
|
|
43023
43120
|
notes
|
|
43024
|
-
}
|
|
43025
|
-
|
|
43026
|
-
});
|
|
43027
|
-
}
|
|
43028
|
-
|
|
43029
|
-
// src/tools/merchants.ts
|
|
43030
|
-
async function categorySlugToId(client) {
|
|
43031
|
-
const res = await client.get("/api/categories");
|
|
43032
|
-
const map2 = new Map;
|
|
43033
|
-
for (const root of res.data ?? []) {
|
|
43034
|
-
if (root.slug)
|
|
43035
|
-
map2.set(root.slug, root.id);
|
|
43036
|
-
for (const child of root.children ?? []) {
|
|
43037
|
-
if (child.slug)
|
|
43038
|
-
map2.set(child.slug, child.id);
|
|
43039
|
-
}
|
|
43040
|
-
}
|
|
43041
|
-
return map2;
|
|
43042
|
-
}
|
|
43043
|
-
function registerMerchantTools(server, client, crypto3) {
|
|
43044
|
-
server.registerTool("upsert_merchant", {
|
|
43045
|
-
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.",
|
|
43046
|
-
inputSchema: {
|
|
43047
|
-
id: exports_external.string().optional().describe("Merchant UUID — pass to update, omit to create"),
|
|
43048
|
-
rawId: exports_external.string().optional().describe("Raw identifier — UPI ID or normalised name (required when creating)"),
|
|
43049
|
-
cleanName: exports_external.string().optional().describe('Human-friendly name (e.g. "DMart")'),
|
|
43050
|
-
categorySlug: exports_external.string().optional().describe("Category slug, e.g. 'food_delivery'"),
|
|
43051
|
-
isPerson: exports_external.boolean().optional(),
|
|
43052
|
-
isRecurring: exports_external.boolean().optional(),
|
|
43053
|
-
notes: exports_external.string().optional()
|
|
43054
|
-
}
|
|
43055
|
-
}, async ({ id, categorySlug, ...rest }) => {
|
|
43056
|
-
const payload = { ...rest };
|
|
43057
|
-
if (categorySlug) {
|
|
43058
|
-
const slugMap = await categorySlugToId(client);
|
|
43059
|
-
const categoryId = slugMap.get(categorySlug);
|
|
43060
|
-
if (!categoryId) {
|
|
43061
|
-
throw new Error(`Unknown categorySlug "${categorySlug}". Use list_categories to see valid slugs.`);
|
|
43062
|
-
}
|
|
43063
|
-
payload.categoryId = categoryId;
|
|
43064
|
-
}
|
|
43065
|
-
const data = id ? await client.patch(`/api/merchants/${id}`, payload) : await client.post("/api/merchants", payload);
|
|
43121
|
+
};
|
|
43122
|
+
const data = id ? await client.patch(`/api/investments/fds/${id}`, fdBody) : await client.post("/api/investments/fds", fdBody);
|
|
43066
43123
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43067
43124
|
});
|
|
43068
|
-
server.registerTool("
|
|
43069
|
-
description: "
|
|
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.",
|
|
43070
43127
|
inputSchema: {
|
|
43071
|
-
|
|
43072
|
-
|
|
43073
|
-
}, async ({ rawId }) => {
|
|
43074
|
-
if (crypto3 && rawId) {
|
|
43075
|
-
const data2 = await client.get("/api/merchants");
|
|
43076
|
-
if (data2.data)
|
|
43077
|
-
await decryptMerchantFields(data2.data, crypto3.key);
|
|
43078
|
-
const needle = rawId.toLowerCase();
|
|
43079
|
-
const merchants = (data2.data ?? []).filter((m) => String(m.rawId ?? "").toLowerCase() === needle || String(m.cleanName ?? "").toLowerCase().includes(needle));
|
|
43080
|
-
return {
|
|
43081
|
-
content: [{ type: "text", text: JSON.stringify({ ...data2, data: merchants }) }]
|
|
43082
|
-
};
|
|
43128
|
+
type: exports_external.enum(["sip", "fixed_deposit"]),
|
|
43129
|
+
id: exports_external.string().describe("SIP or FD UUID")
|
|
43083
43130
|
}
|
|
43084
|
-
|
|
43085
|
-
|
|
43086
|
-
}) : await client.get("/api/merchants");
|
|
43087
|
-
if (crypto3 && data.data)
|
|
43088
|
-
await decryptMerchantFields(data.data, crypto3.key);
|
|
43131
|
+
}, async ({ type, id }) => {
|
|
43132
|
+
const data = type === "sip" ? await client.delete(`/api/investments/sips/${id}`) : await client.delete(`/api/investments/fds/${id}`);
|
|
43089
43133
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43090
43134
|
});
|
|
43091
|
-
server.registerTool("
|
|
43092
|
-
description: "
|
|
43093
|
-
inputSchema: {
|
|
43094
|
-
|
|
43095
|
-
|
|
43096
|
-
}
|
|
43097
|
-
}, async ({ id, ids }) => {
|
|
43098
|
-
if (!id && (!ids || ids.length === 0)) {
|
|
43099
|
-
throw new Error("Provide either id or ids");
|
|
43100
|
-
}
|
|
43101
|
-
const data = ids ? await client.post("/api/merchants/bulk-delete", { ids }) : await client.delete(`/api/merchants/${id}`);
|
|
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");
|
|
43102
43140
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43103
43141
|
});
|
|
43104
43142
|
}
|
|
43105
43143
|
|
|
43106
|
-
//
|
|
43107
|
-
|
|
43108
|
-
|
|
43109
|
-
|
|
43110
|
-
|
|
43111
|
-
|
|
43112
|
-
|
|
43113
|
-
|
|
43114
|
-
|
|
43115
|
-
|
|
43116
|
-
|
|
43117
|
-
|
|
43118
|
-
|
|
43119
|
-
|
|
43120
|
-
|
|
43121
|
-
|
|
43122
|
-
|
|
43123
|
-
|
|
43124
|
-
|
|
43125
|
-
|
|
43144
|
+
// ../../node_modules/.bun/@typesafe-ai+sdk@0.6.0/node_modules/@typesafe-ai/sdk/dist/index.mjs
|
|
43145
|
+
var requestIdFrom = (headers) => headers.get("x-typesafe-request-id") ?? undefined;
|
|
43146
|
+
var APIPromise = class APIPromise2 extends Promise {
|
|
43147
|
+
#responsePromise;
|
|
43148
|
+
#parseResponse;
|
|
43149
|
+
#parsed;
|
|
43150
|
+
constructor(responsePromise, parseResponse) {
|
|
43151
|
+
super((resolve) => resolve(undefined));
|
|
43152
|
+
this.#responsePromise = responsePromise;
|
|
43153
|
+
this.#parseResponse = parseResponse;
|
|
43154
|
+
}
|
|
43155
|
+
asResponse() {
|
|
43156
|
+
return this.#responsePromise;
|
|
43157
|
+
}
|
|
43158
|
+
async withResponse() {
|
|
43159
|
+
const [data, response] = await Promise.all([this.#parse(), this.#responsePromise]);
|
|
43160
|
+
return {
|
|
43161
|
+
data,
|
|
43162
|
+
response,
|
|
43163
|
+
requestId: requestIdFrom(response.headers)
|
|
43164
|
+
};
|
|
43165
|
+
}
|
|
43166
|
+
map(fn) {
|
|
43167
|
+
return new APIPromise2(this.#responsePromise, () => this.#parse().then(fn));
|
|
43168
|
+
}
|
|
43169
|
+
#parse() {
|
|
43170
|
+
this.#parsed ??= this.#responsePromise.then(this.#parseResponse);
|
|
43171
|
+
return this.#parsed;
|
|
43172
|
+
}
|
|
43173
|
+
then(onfulfilled, onrejected) {
|
|
43174
|
+
return this.#parse().then(onfulfilled, onrejected);
|
|
43175
|
+
}
|
|
43176
|
+
catch(onrejected) {
|
|
43177
|
+
return this.#parse().catch(onrejected);
|
|
43178
|
+
}
|
|
43179
|
+
finally(onfinally) {
|
|
43180
|
+
return this.#parse().finally(onfinally);
|
|
43181
|
+
}
|
|
43182
|
+
};
|
|
43183
|
+
var ENV = {
|
|
43184
|
+
apiKey: "TYPESAFE_API_KEY",
|
|
43185
|
+
baseURL: "TYPESAFE_BASE_URL",
|
|
43186
|
+
defaultModel: "TYPESAFE_DEFAULT_MODEL",
|
|
43187
|
+
logLevel: "TYPESAFE_LOG_LEVEL"
|
|
43188
|
+
};
|
|
43189
|
+
var readEnv = (name) => {
|
|
43190
|
+
if (typeof process === "undefined" || !process.env)
|
|
43191
|
+
return;
|
|
43192
|
+
return process.env[name]?.trim() || undefined;
|
|
43193
|
+
};
|
|
43194
|
+
var fromCodeOrEnv = (fromCode, envVar) => fromCode ?? readEnv(envVar);
|
|
43195
|
+
var range = (from, to) => Array.from({ length: to - from }, (_, i) => from + i);
|
|
43196
|
+
var DEFAULT_RETRY_POLICY = {
|
|
43197
|
+
maxRetries: 2,
|
|
43198
|
+
backoffInitialMs: 500,
|
|
43199
|
+
backoffMaxMs: 5000,
|
|
43200
|
+
backoffJitter: 0.25,
|
|
43201
|
+
httpStatuses: /* @__PURE__ */ new Set([
|
|
43202
|
+
408,
|
|
43203
|
+
429,
|
|
43204
|
+
...range(500, 600)
|
|
43205
|
+
]),
|
|
43206
|
+
respectRetryAfter: true,
|
|
43207
|
+
maxRetryAfterMs: 60000,
|
|
43208
|
+
apiConnectionError: true,
|
|
43209
|
+
apiTimeoutError: true
|
|
43210
|
+
};
|
|
43211
|
+
DEFAULT_RETRY_POLICY.maxRetries;
|
|
43212
|
+
var isRetryableStatus = (status, policy = DEFAULT_RETRY_POLICY) => policy.httpStatuses.has(status);
|
|
43213
|
+
var parseRetryAfter = (headers, now = Date.now()) => {
|
|
43214
|
+
const ms = Number(headers.get("retry-after-ms"));
|
|
43215
|
+
if (headers.has("retry-after-ms") && Number.isFinite(ms) && ms >= 0)
|
|
43216
|
+
return ms;
|
|
43217
|
+
const raw = headers.get("retry-after");
|
|
43218
|
+
if (raw === null)
|
|
43219
|
+
return;
|
|
43220
|
+
const seconds = Number(raw);
|
|
43221
|
+
if (Number.isFinite(seconds))
|
|
43222
|
+
return seconds >= 0 ? seconds * 1000 : undefined;
|
|
43223
|
+
const date4 = Date.parse(raw);
|
|
43224
|
+
if (!Number.isNaN(date4))
|
|
43225
|
+
return Math.max(0, date4 - now);
|
|
43226
|
+
};
|
|
43227
|
+
var retryDelayMs = (attempt, headers, policy = DEFAULT_RETRY_POLICY, random = Math.random) => {
|
|
43228
|
+
if (policy.respectRetryAfter && headers !== undefined) {
|
|
43229
|
+
const retryAfter = parseRetryAfter(headers);
|
|
43230
|
+
if (retryAfter !== undefined && retryAfter <= policy.maxRetryAfterMs)
|
|
43231
|
+
return retryAfter;
|
|
43232
|
+
}
|
|
43233
|
+
const exponential = Math.min(policy.backoffInitialMs * 2 ** attempt, policy.backoffMaxMs);
|
|
43234
|
+
return Math.round(exponential * (1 - random() * policy.backoffJitter));
|
|
43235
|
+
};
|
|
43236
|
+
var sleep = (ms, signal) => new Promise((resolve, reject) => {
|
|
43237
|
+
if (signal?.aborted)
|
|
43238
|
+
return reject(signal.reason);
|
|
43239
|
+
const onAbort = () => {
|
|
43240
|
+
clearTimeout(timer);
|
|
43241
|
+
reject(signal?.reason);
|
|
43242
|
+
};
|
|
43243
|
+
const timer = setTimeout(() => {
|
|
43244
|
+
signal?.removeEventListener("abort", onAbort);
|
|
43245
|
+
resolve();
|
|
43246
|
+
}, ms);
|
|
43247
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
43248
|
+
});
|
|
43249
|
+
var TypeSafeError = class extends Error {
|
|
43250
|
+
constructor(message, options) {
|
|
43251
|
+
super(message, options);
|
|
43252
|
+
this.name = new.target.name;
|
|
43253
|
+
}
|
|
43254
|
+
};
|
|
43255
|
+
var isRecord = (value) => typeof value === "object" && value !== null;
|
|
43256
|
+
var extractMessage = (body) => {
|
|
43257
|
+
if (typeof body === "string")
|
|
43258
|
+
return body || undefined;
|
|
43259
|
+
if (!isRecord(body))
|
|
43260
|
+
return;
|
|
43261
|
+
const { error: error2, message, detail } = body;
|
|
43262
|
+
if (typeof error2 === "string")
|
|
43263
|
+
return error2;
|
|
43264
|
+
if (isRecord(error2) && typeof error2.message === "string")
|
|
43265
|
+
return error2.message;
|
|
43266
|
+
if (typeof message === "string")
|
|
43267
|
+
return message;
|
|
43268
|
+
if (typeof detail === "string")
|
|
43269
|
+
return detail;
|
|
43270
|
+
if (isRecord(detail) && typeof detail.message === "string")
|
|
43271
|
+
return detail.message;
|
|
43272
|
+
if (Array.isArray(detail))
|
|
43273
|
+
return describeValidationErrors(detail);
|
|
43274
|
+
};
|
|
43275
|
+
var describeValidationErrors = (errors4) => {
|
|
43276
|
+
const parts = errors4.flatMap((e) => {
|
|
43277
|
+
if (!isRecord(e) || typeof e.msg !== "string")
|
|
43278
|
+
return [];
|
|
43279
|
+
const loc = Array.isArray(e.loc) ? e.loc.filter((x) => x !== "body").join(".") : "";
|
|
43280
|
+
return [loc ? `${loc}: ${e.msg}` : e.msg];
|
|
43281
|
+
});
|
|
43282
|
+
return parts.length > 0 ? parts.join("; ") : undefined;
|
|
43283
|
+
};
|
|
43284
|
+
var MAX_RAW_BODY_IN_MESSAGE = 200;
|
|
43285
|
+
var APIError = class APIError2 extends TypeSafeError {
|
|
43286
|
+
status;
|
|
43287
|
+
headers;
|
|
43288
|
+
body;
|
|
43289
|
+
requestId;
|
|
43290
|
+
constructor(status, body, headers, message) {
|
|
43291
|
+
super(message ?? APIError2.describe(status, body));
|
|
43292
|
+
this.status = status;
|
|
43293
|
+
this.body = body;
|
|
43294
|
+
this.headers = headers;
|
|
43295
|
+
this.requestId = requestIdFrom(headers);
|
|
43296
|
+
}
|
|
43297
|
+
static describe(status, body) {
|
|
43298
|
+
const detail = extractMessage(body);
|
|
43299
|
+
if (detail)
|
|
43300
|
+
return `${status} ${detail}`;
|
|
43301
|
+
if (body === undefined)
|
|
43302
|
+
return `${status} status code (no body)`;
|
|
43303
|
+
const raw = typeof body === "string" ? body : JSON.stringify(body);
|
|
43304
|
+
return `${status} ${raw.length > MAX_RAW_BODY_IN_MESSAGE ? `${raw.slice(0, MAX_RAW_BODY_IN_MESSAGE)}…` : raw}`;
|
|
43305
|
+
}
|
|
43306
|
+
static fromResponse(status, body, headers) {
|
|
43307
|
+
if (status === 400)
|
|
43308
|
+
return new BadRequestError(status, body, headers);
|
|
43309
|
+
if (status === 401)
|
|
43310
|
+
return new AuthenticationError(status, body, headers);
|
|
43311
|
+
if (status === 403)
|
|
43312
|
+
return new PermissionDeniedError(status, body, headers);
|
|
43313
|
+
if (status === 404)
|
|
43314
|
+
return new NotFoundError(status, body, headers);
|
|
43315
|
+
if (status === 422)
|
|
43316
|
+
return new UnprocessableEntityError(status, body, headers);
|
|
43317
|
+
if (status === 429)
|
|
43318
|
+
return new RateLimitError(status, body, headers);
|
|
43319
|
+
if (status >= 500)
|
|
43320
|
+
return new InternalServerError(status, body, headers);
|
|
43321
|
+
return new APIError2(status, body, headers);
|
|
43322
|
+
}
|
|
43323
|
+
};
|
|
43324
|
+
var BadRequestError = class extends APIError {
|
|
43325
|
+
};
|
|
43326
|
+
var AuthenticationError = class extends APIError {
|
|
43327
|
+
};
|
|
43328
|
+
var PermissionDeniedError = class extends APIError {
|
|
43329
|
+
};
|
|
43330
|
+
var NotFoundError = class extends APIError {
|
|
43331
|
+
};
|
|
43332
|
+
var UnprocessableEntityError = class extends APIError {
|
|
43333
|
+
};
|
|
43334
|
+
var RateLimitError = class extends APIError {
|
|
43335
|
+
retryAfterMs = parseRetryAfter(this.headers);
|
|
43336
|
+
};
|
|
43337
|
+
var InternalServerError = class extends APIError {
|
|
43338
|
+
};
|
|
43339
|
+
var APIConnectionError = class extends TypeSafeError {
|
|
43340
|
+
constructor(message = "Connection error.", options) {
|
|
43341
|
+
super(message, options);
|
|
43342
|
+
}
|
|
43343
|
+
};
|
|
43344
|
+
var APITimeoutError = class extends APIConnectionError {
|
|
43345
|
+
timeoutMs;
|
|
43346
|
+
constructor(timeoutMs, options) {
|
|
43347
|
+
super(`Request timed out after ${timeoutMs}ms.`, options);
|
|
43348
|
+
this.timeoutMs = timeoutMs;
|
|
43349
|
+
}
|
|
43350
|
+
};
|
|
43351
|
+
var APIUserAbortError = class extends TypeSafeError {
|
|
43352
|
+
constructor(message = "Request was aborted.", options) {
|
|
43353
|
+
super(message, options);
|
|
43354
|
+
}
|
|
43355
|
+
};
|
|
43356
|
+
var LOG_LEVELS = [
|
|
43357
|
+
"debug",
|
|
43358
|
+
"info",
|
|
43359
|
+
"warn",
|
|
43360
|
+
"error",
|
|
43361
|
+
"off"
|
|
43362
|
+
];
|
|
43363
|
+
var DEFAULT_LOG_LEVEL = "warn";
|
|
43364
|
+
var isLogLevel = (value) => LOG_LEVELS.includes(value);
|
|
43365
|
+
var parseLogLevel = (value, source) => {
|
|
43366
|
+
if (isLogLevel(value))
|
|
43367
|
+
return value;
|
|
43368
|
+
throw new TypeSafeError(`Invalid log level "${value}" from ${source}. Expected one of: ${LOG_LEVELS.join(", ")}.`);
|
|
43369
|
+
};
|
|
43370
|
+
var PREFIX = "[typesafe-sdk]";
|
|
43371
|
+
var consoleLogger = {
|
|
43372
|
+
debug: (message, ...args) => console.debug(`${PREFIX} ${message}`, ...args),
|
|
43373
|
+
info: (message, ...args) => console.info(`${PREFIX} ${message}`, ...args),
|
|
43374
|
+
warn: (message, ...args) => console.warn(`${PREFIX} ${message}`, ...args),
|
|
43375
|
+
error: (message, ...args) => console.error(`${PREFIX} ${message}`, ...args)
|
|
43376
|
+
};
|
|
43377
|
+
var RANK = {
|
|
43378
|
+
debug: 0,
|
|
43379
|
+
info: 1,
|
|
43380
|
+
warn: 2,
|
|
43381
|
+
error: 3,
|
|
43382
|
+
off: 4
|
|
43383
|
+
};
|
|
43384
|
+
var drop = () => {};
|
|
43385
|
+
var withLevel = (sink, level) => {
|
|
43386
|
+
const enabled = (at) => RANK[at] >= RANK[level];
|
|
43387
|
+
return {
|
|
43388
|
+
debug: enabled("debug") ? (message, ...args) => sink.debug(message, ...args) : drop,
|
|
43389
|
+
info: enabled("info") ? (message, ...args) => sink.info(message, ...args) : drop,
|
|
43390
|
+
warn: enabled("warn") ? (message, ...args) => sink.warn(message, ...args) : drop,
|
|
43391
|
+
error: enabled("error") ? (message, ...args) => sink.error(message, ...args) : drop
|
|
43392
|
+
};
|
|
43393
|
+
};
|
|
43394
|
+
var KEY_HEADERS = /* @__PURE__ */ new Set([
|
|
43395
|
+
"authorization",
|
|
43396
|
+
"proxy-authorization",
|
|
43397
|
+
"x-api-key"
|
|
43398
|
+
]);
|
|
43399
|
+
var OPAQUE_HEADERS = /* @__PURE__ */ new Set(["cookie", "set-cookie"]);
|
|
43400
|
+
var redactKey = (value) => {
|
|
43401
|
+
const [scheme, secret] = value.includes(" ") ? value.split(/\s+/, 2) : [undefined, value];
|
|
43402
|
+
const tail = secret && secret.length > 8 ? secret.slice(-4) : "";
|
|
43403
|
+
return `${scheme ? `${scheme} ` : ""}***${tail}`;
|
|
43404
|
+
};
|
|
43405
|
+
var redact = (name, value) => {
|
|
43406
|
+
const lower = name.toLowerCase();
|
|
43407
|
+
if (KEY_HEADERS.has(lower))
|
|
43408
|
+
return redactKey(value);
|
|
43409
|
+
if (OPAQUE_HEADERS.has(lower))
|
|
43410
|
+
return "***";
|
|
43411
|
+
return value;
|
|
43412
|
+
};
|
|
43413
|
+
var redactHeaders = (headers) => Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, redact(name, value)]));
|
|
43414
|
+
var choice = (instructions, criteria) => {
|
|
43415
|
+
if (Array.isArray(criteria))
|
|
43416
|
+
throw new TypeSafeError("Choice criteria must be a map of labels to descriptions, not a list.");
|
|
43417
|
+
return {
|
|
43418
|
+
type: "choice",
|
|
43419
|
+
instructions,
|
|
43420
|
+
criteria
|
|
43421
|
+
};
|
|
43422
|
+
};
|
|
43423
|
+
var validateQuestions = (questions) => {
|
|
43424
|
+
if (Object.keys(questions).length === 0)
|
|
43425
|
+
throw new TypeSafeError("At least one question is required.");
|
|
43426
|
+
for (const [name, question] of Object.entries(questions)) {
|
|
43427
|
+
if (question.type !== "score")
|
|
43428
|
+
continue;
|
|
43429
|
+
if (!Array.isArray(question.criteria))
|
|
43430
|
+
throw new TypeSafeError(`Score question "${name}" has criteria that are not a list; score criteria must be a list of descriptions indexed by score from zero.`);
|
|
43431
|
+
if (question.criteria.length < 2)
|
|
43432
|
+
throw new TypeSafeError(`Score question "${name}" has ${question.criteria.length} criteria; at least two scores are required.`);
|
|
43433
|
+
}
|
|
43434
|
+
};
|
|
43435
|
+
var Models = class {
|
|
43436
|
+
#transport;
|
|
43437
|
+
constructor(transport) {
|
|
43438
|
+
this.#transport = transport;
|
|
43439
|
+
}
|
|
43440
|
+
list(options = {}) {
|
|
43441
|
+
return this.#transport.request("GET", "/v1/models", options).map(unwrapModels);
|
|
43442
|
+
}
|
|
43443
|
+
};
|
|
43444
|
+
var unwrapModels = (wire) => {
|
|
43445
|
+
if (Array.isArray(wire?.models))
|
|
43446
|
+
return wire.models;
|
|
43447
|
+
throw new TypeSafeError("Unexpected response shape from GET /v1/models; expected { models: [...] }.");
|
|
43448
|
+
};
|
|
43449
|
+
var g = globalThis;
|
|
43450
|
+
var isBrowser = () => typeof g.window !== "undefined" && typeof g.window.document !== "undefined" && typeof g.navigator !== "undefined";
|
|
43451
|
+
var describeRuntime = () => {
|
|
43452
|
+
const platform = g.process?.platform && g.process?.arch ? ` (${g.process.platform}; ${g.process.arch})` : "";
|
|
43453
|
+
if (g.Bun?.version)
|
|
43454
|
+
return `bun/${g.Bun.version}${platform}`;
|
|
43455
|
+
if (g.Deno?.version?.deno)
|
|
43456
|
+
return `deno/${g.Deno.version.deno}${platform}`;
|
|
43457
|
+
if (g.EdgeRuntime !== undefined)
|
|
43458
|
+
return "vercel-edge";
|
|
43459
|
+
if (g.navigator?.userAgent === "Cloudflare-Workers")
|
|
43460
|
+
return "cloudflare-workers";
|
|
43461
|
+
if (g.process?.versions?.node)
|
|
43462
|
+
return `node/${g.process.versions.node}${platform}`;
|
|
43463
|
+
if (isBrowser())
|
|
43464
|
+
return "browser";
|
|
43465
|
+
return "unknown";
|
|
43466
|
+
};
|
|
43467
|
+
var VERSION2 = "0.6.0";
|
|
43468
|
+
var missingApiKey = () => {
|
|
43469
|
+
throw new TypeSafeError(`No API key was provided. Pass \`apiKey\` to the TypeSafeClient constructor or set the ${ENV.apiKey} environment variable.`);
|
|
43470
|
+
};
|
|
43471
|
+
var missingFetch = () => {
|
|
43472
|
+
throw new TypeSafeError("No global `fetch` is available in this runtime. Pass a `fetch` implementation to the TypeSafeClient constructor.");
|
|
43473
|
+
};
|
|
43474
|
+
var refuseBrowser = () => {
|
|
43475
|
+
throw new TypeSafeError("TypeSafeClient is running in a browser, which would expose your API key to anyone using the page. Call the API from a server instead, or pass `dangerouslyAllowBrowser: true` if you understand the risk.");
|
|
43476
|
+
};
|
|
43477
|
+
var defaultFetch = (input, init) => globalThis.fetch(input, init);
|
|
43478
|
+
var assertNonNegativeInteger = (name, value) => {
|
|
43479
|
+
if (!Number.isInteger(value) || value < 0)
|
|
43480
|
+
throw new TypeSafeError(`\`${name}\` must be a non-negative integer, got ${String(value)}.`);
|
|
43481
|
+
return value;
|
|
43482
|
+
};
|
|
43483
|
+
var assertPositiveMs = (name, value) => {
|
|
43484
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
43485
|
+
throw new TypeSafeError(`\`${name}\` must be a positive number of milliseconds, got ${String(value)}.`);
|
|
43486
|
+
return value;
|
|
43487
|
+
};
|
|
43488
|
+
var assertNonNegativeMs = (name, value) => {
|
|
43489
|
+
if (!Number.isFinite(value) || value < 0)
|
|
43490
|
+
throw new TypeSafeError(`\`${name}\` must be a non-negative number of milliseconds, got ${String(value)}.`);
|
|
43491
|
+
return value;
|
|
43492
|
+
};
|
|
43493
|
+
var assertFraction = (name, value) => {
|
|
43494
|
+
if (!Number.isFinite(value) || value < 0 || value > 1)
|
|
43495
|
+
throw new TypeSafeError(`\`${name}\` must be between 0 and 1, got ${String(value)}.`);
|
|
43496
|
+
return value;
|
|
43497
|
+
};
|
|
43498
|
+
var assertStatusSet = (name, statuses) => {
|
|
43499
|
+
for (const status of statuses)
|
|
43500
|
+
if (!Number.isInteger(status) || status < 100 || status > 999)
|
|
43501
|
+
throw new TypeSafeError(`\`${name}\` must contain HTTP status codes, got ${String(status)}.`);
|
|
43502
|
+
return statuses;
|
|
43503
|
+
};
|
|
43504
|
+
var resolveRetryPolicy = (base, overrides) => {
|
|
43505
|
+
const o = overrides ?? {};
|
|
43506
|
+
return {
|
|
43507
|
+
maxRetries: o.maxRetries === undefined ? base.maxRetries : assertNonNegativeInteger("retry.maxRetries", o.maxRetries),
|
|
43508
|
+
backoffInitialMs: o.backoffInitialMs === undefined ? base.backoffInitialMs : assertNonNegativeMs("retry.backoffInitialMs", o.backoffInitialMs),
|
|
43509
|
+
backoffMaxMs: o.backoffMaxMs === undefined ? base.backoffMaxMs : assertNonNegativeMs("retry.backoffMaxMs", o.backoffMaxMs),
|
|
43510
|
+
backoffJitter: o.backoffJitter === undefined ? base.backoffJitter : assertFraction("retry.backoffJitter", o.backoffJitter),
|
|
43511
|
+
httpStatuses: new Set(o.httpStatuses === undefined ? base.httpStatuses : assertStatusSet("retry.httpStatuses", o.httpStatuses)),
|
|
43512
|
+
respectRetryAfter: o.respectRetryAfter ?? base.respectRetryAfter,
|
|
43513
|
+
maxRetryAfterMs: o.maxRetryAfterMs === undefined ? base.maxRetryAfterMs : assertNonNegativeMs("retry.maxRetryAfterMs", o.maxRetryAfterMs),
|
|
43514
|
+
apiConnectionError: o.apiConnectionError ?? base.apiConnectionError,
|
|
43515
|
+
apiTimeoutError: o.apiTimeoutError ?? base.apiTimeoutError
|
|
43516
|
+
};
|
|
43517
|
+
};
|
|
43518
|
+
var isRetryableError = (err, policy) => {
|
|
43519
|
+
if (err instanceof APITimeoutError)
|
|
43520
|
+
return policy.apiTimeoutError;
|
|
43521
|
+
if (err instanceof APIConnectionError)
|
|
43522
|
+
return policy.apiConnectionError;
|
|
43523
|
+
return false;
|
|
43524
|
+
};
|
|
43525
|
+
var resolveLogLevel = (fromCode) => {
|
|
43526
|
+
if (fromCode !== undefined)
|
|
43527
|
+
return parseLogLevel(fromCode, "the `logLevel` option");
|
|
43528
|
+
const fromEnv = readEnv(ENV.logLevel);
|
|
43529
|
+
if (fromEnv !== undefined)
|
|
43530
|
+
return parseLogLevel(fromEnv, ENV.logLevel);
|
|
43531
|
+
return DEFAULT_LOG_LEVEL;
|
|
43532
|
+
};
|
|
43533
|
+
var stripTrailingSlashes = (url2) => url2.replace(/\/+$/, "");
|
|
43534
|
+
var mergeHeaders = (...sources) => {
|
|
43535
|
+
const entries = /* @__PURE__ */ new Map;
|
|
43536
|
+
for (const source of sources)
|
|
43537
|
+
for (const [name, value] of Object.entries(source))
|
|
43538
|
+
if (value === undefined)
|
|
43539
|
+
entries.delete(name.toLowerCase());
|
|
43540
|
+
else
|
|
43541
|
+
entries.set(name.toLowerCase(), [name, value]);
|
|
43542
|
+
return Object.fromEntries(entries.values());
|
|
43543
|
+
};
|
|
43544
|
+
var bufferResponse = async (response, signal) => {
|
|
43545
|
+
const reader = response.clone().body?.getReader();
|
|
43546
|
+
if (!reader)
|
|
43547
|
+
return;
|
|
43548
|
+
const cancel = () => {
|
|
43549
|
+
reader.cancel(signal.reason).catch(() => {});
|
|
43550
|
+
response.body?.cancel(signal.reason).catch(() => {});
|
|
43551
|
+
};
|
|
43552
|
+
signal.addEventListener("abort", cancel, { once: true });
|
|
43553
|
+
try {
|
|
43554
|
+
if (signal.aborted)
|
|
43555
|
+
cancel();
|
|
43556
|
+
signal.throwIfAborted();
|
|
43557
|
+
while (!(await reader.read()).done)
|
|
43558
|
+
signal.throwIfAborted();
|
|
43559
|
+
signal.throwIfAborted();
|
|
43560
|
+
} finally {
|
|
43561
|
+
signal.removeEventListener("abort", cancel);
|
|
43562
|
+
reader.releaseLock();
|
|
43563
|
+
}
|
|
43564
|
+
};
|
|
43565
|
+
var RUNTIME = describeRuntime();
|
|
43566
|
+
var TypeSafeClient = class {
|
|
43567
|
+
#apiKey;
|
|
43568
|
+
baseURL;
|
|
43569
|
+
defaultModel;
|
|
43570
|
+
logLevel;
|
|
43571
|
+
logger;
|
|
43572
|
+
retry;
|
|
43573
|
+
timeout;
|
|
43574
|
+
defaultHeaders;
|
|
43575
|
+
fetch;
|
|
43576
|
+
models;
|
|
43577
|
+
#requestCount = 0;
|
|
43578
|
+
constructor(config2 = {}) {
|
|
43579
|
+
if (isBrowser() && !config2.dangerouslyAllowBrowser)
|
|
43580
|
+
refuseBrowser();
|
|
43581
|
+
this.#apiKey = fromCodeOrEnv(config2.apiKey, ENV.apiKey) ?? missingApiKey();
|
|
43582
|
+
this.baseURL = stripTrailingSlashes(fromCodeOrEnv(config2.baseURL, ENV.baseURL) ?? "https://api.typesafe.ai");
|
|
43583
|
+
this.defaultModel = fromCodeOrEnv(config2.defaultModel, ENV.defaultModel) ?? "jev-latest";
|
|
43584
|
+
this.logLevel = resolveLogLevel(config2.logLevel);
|
|
43585
|
+
this.logger = withLevel(config2.logger ?? consoleLogger, this.logLevel);
|
|
43586
|
+
this.retry = resolveRetryPolicy(DEFAULT_RETRY_POLICY, config2.retry);
|
|
43587
|
+
this.timeout = assertPositiveMs("timeout", config2.timeout ?? 1e4);
|
|
43588
|
+
this.defaultHeaders = { ...config2.defaultHeaders };
|
|
43589
|
+
if (config2.fetch === undefined && typeof globalThis.fetch !== "function")
|
|
43590
|
+
missingFetch();
|
|
43591
|
+
this.fetch = config2.fetch ?? defaultFetch;
|
|
43592
|
+
const transport = {
|
|
43593
|
+
request: (method, path, options) => this.#request(method, path, options),
|
|
43594
|
+
defaultModel: this.defaultModel
|
|
43595
|
+
};
|
|
43596
|
+
this.models = new Models(transport);
|
|
43597
|
+
}
|
|
43598
|
+
systemOne(request, options = {}) {
|
|
43599
|
+
validateQuestions(request.questions);
|
|
43600
|
+
const body = {
|
|
43601
|
+
...request,
|
|
43602
|
+
model: request.model ?? this.defaultModel
|
|
43603
|
+
};
|
|
43604
|
+
return this.#request("POST", "/v1/systemone", {
|
|
43605
|
+
...options,
|
|
43606
|
+
body
|
|
43607
|
+
});
|
|
43608
|
+
}
|
|
43609
|
+
#request(method, path, options = {}) {
|
|
43610
|
+
const resolved = {
|
|
43611
|
+
method,
|
|
43612
|
+
path,
|
|
43613
|
+
body: options.body,
|
|
43614
|
+
headers: mergeHeaders(this.defaultHeaders, options.headers ?? {}),
|
|
43615
|
+
signal: options.signal,
|
|
43616
|
+
timeout: options.timeout === undefined ? this.timeout : assertPositiveMs("timeout", options.timeout),
|
|
43617
|
+
retry: resolveRetryPolicy(this.retry, options.retry)
|
|
43618
|
+
};
|
|
43619
|
+
const tag = `#${++this.#requestCount} ${method} ${path}`;
|
|
43620
|
+
return new APIPromise(this.fetchWithRetries(tag, resolved), async (res) => {
|
|
43621
|
+
const parsed = await parseBody(res);
|
|
43622
|
+
this.logger.debug(`${tag} <- body`, parsed);
|
|
43623
|
+
return parsed;
|
|
43624
|
+
});
|
|
43625
|
+
}
|
|
43626
|
+
async fetchWithRetries(tag, req) {
|
|
43627
|
+
const url2 = `${this.baseURL}${req.path}`;
|
|
43628
|
+
const headers = mergeHeaders(req.headers, {
|
|
43629
|
+
Authorization: `Bearer ${this.#apiKey}`,
|
|
43630
|
+
Accept: "application/json",
|
|
43631
|
+
"User-Agent": `typesafe-sdk/${VERSION2}`,
|
|
43632
|
+
"X-TypeSafe-SDK": `typesafe-sdk/${VERSION2}`,
|
|
43633
|
+
"X-TypeSafe-Runtime": RUNTIME,
|
|
43634
|
+
"Content-Type": req.body === undefined ? undefined : "application/json",
|
|
43635
|
+
"X-TypeSafe-Retry-Count": undefined
|
|
43636
|
+
});
|
|
43637
|
+
const body = req.body === undefined ? undefined : JSON.stringify(req.body);
|
|
43638
|
+
for (let attempt = 0;; attempt++) {
|
|
43639
|
+
const retriesLeft = req.retry.maxRetries - attempt;
|
|
43640
|
+
const attemptHeaders = attempt === 0 ? headers : {
|
|
43641
|
+
...headers,
|
|
43642
|
+
"X-TypeSafe-Retry-Count": String(attempt)
|
|
43643
|
+
};
|
|
43644
|
+
this.logger.debug(`${tag} -> ${url2}`, {
|
|
43645
|
+
headers: redactHeaders(attemptHeaders),
|
|
43646
|
+
body: req.body
|
|
43647
|
+
});
|
|
43648
|
+
const started = Date.now();
|
|
43649
|
+
let res;
|
|
43650
|
+
try {
|
|
43651
|
+
res = await this.attempt(tag, url2, {
|
|
43652
|
+
method: req.method,
|
|
43653
|
+
headers: attemptHeaders,
|
|
43654
|
+
body
|
|
43655
|
+
}, req);
|
|
43656
|
+
} catch (err) {
|
|
43657
|
+
if (err instanceof APIUserAbortError || retriesLeft <= 0)
|
|
43658
|
+
throw err;
|
|
43659
|
+
if (!isRetryableError(err, req.retry))
|
|
43660
|
+
throw err;
|
|
43661
|
+
await this.backOff(tag, attempt, retriesLeft, err.message, undefined, req);
|
|
43662
|
+
continue;
|
|
43663
|
+
}
|
|
43664
|
+
const requestId = requestIdFrom(res.headers);
|
|
43665
|
+
this.logger.info(`${tag} <- ${res.status} in ${Date.now() - started}ms${requestId ? ` (request ${requestId})` : ""}`);
|
|
43666
|
+
if (res.ok)
|
|
43667
|
+
return res;
|
|
43668
|
+
const errorBody = await parseBody(res);
|
|
43669
|
+
this.logger.debug(`${tag} <- error body`, errorBody);
|
|
43670
|
+
const error2 = APIError.fromResponse(res.status, errorBody, res.headers);
|
|
43671
|
+
if (retriesLeft <= 0 || !isRetryableStatus(res.status, req.retry))
|
|
43672
|
+
throw error2;
|
|
43673
|
+
await this.backOff(tag, attempt, retriesLeft, `${res.status}`, res.headers, req);
|
|
43126
43674
|
}
|
|
43127
|
-
}
|
|
43128
|
-
|
|
43129
|
-
|
|
43130
|
-
|
|
43131
|
-
|
|
43132
|
-
|
|
43133
|
-
|
|
43134
|
-
|
|
43135
|
-
|
|
43136
|
-
|
|
43137
|
-
|
|
43138
|
-
|
|
43139
|
-
|
|
43140
|
-
|
|
43675
|
+
}
|
|
43676
|
+
async attempt(tag, url2, init, { signal, timeout }) {
|
|
43677
|
+
const controller = new AbortController;
|
|
43678
|
+
const abortFromCaller = () => controller.abort(signal?.reason);
|
|
43679
|
+
if (signal?.aborted)
|
|
43680
|
+
abortFromCaller();
|
|
43681
|
+
signal?.addEventListener("abort", abortFromCaller, { once: true });
|
|
43682
|
+
let timedOut = false;
|
|
43683
|
+
const timer = setTimeout(() => {
|
|
43684
|
+
timedOut = true;
|
|
43685
|
+
controller.abort();
|
|
43686
|
+
}, timeout);
|
|
43687
|
+
const started = Date.now();
|
|
43688
|
+
const elapsed = () => `${Date.now() - started}ms`;
|
|
43689
|
+
try {
|
|
43690
|
+
const response = await this.fetch(url2, {
|
|
43691
|
+
...init,
|
|
43692
|
+
signal: controller.signal
|
|
43141
43693
|
});
|
|
43694
|
+
await bufferResponse(response, controller.signal);
|
|
43695
|
+
return response;
|
|
43696
|
+
} catch (err) {
|
|
43697
|
+
if (signal?.aborted) {
|
|
43698
|
+
this.logger.info(`${tag} aborted by caller after ${elapsed()}`);
|
|
43699
|
+
throw new APIUserAbortError(undefined, { cause: err });
|
|
43700
|
+
}
|
|
43701
|
+
if (timedOut) {
|
|
43702
|
+
this.logger.info(`${tag} timed out after ${elapsed()}`);
|
|
43703
|
+
throw new APITimeoutError(timeout, { cause: err });
|
|
43704
|
+
}
|
|
43705
|
+
this.logger.info(`${tag} connection error after ${elapsed()}`, err);
|
|
43706
|
+
throw new APIConnectionError(err instanceof Error ? `Connection error: ${err.message}` : undefined, { cause: err });
|
|
43707
|
+
} finally {
|
|
43708
|
+
clearTimeout(timer);
|
|
43709
|
+
signal?.removeEventListener("abort", abortFromCaller);
|
|
43142
43710
|
}
|
|
43143
|
-
|
|
43144
|
-
})
|
|
43145
|
-
|
|
43146
|
-
|
|
43147
|
-
|
|
43148
|
-
|
|
43149
|
-
|
|
43150
|
-
|
|
43151
|
-
|
|
43152
|
-
|
|
43153
|
-
|
|
43154
|
-
upiHandles: exports_external.array(exports_external.string()).optional().describe("UPI ids the person uses"),
|
|
43155
|
-
phoneNumbers: exports_external.array(exports_external.string()).optional(),
|
|
43156
|
-
relationship: exports_external.string().optional().describe('e.g. "sister", "friend", "landlord"'),
|
|
43157
|
-
notes: exports_external.string().optional()
|
|
43158
|
-
}
|
|
43159
|
-
}, async ({ id, ...updates }) => {
|
|
43160
|
-
if (crypto3) {
|
|
43161
|
-
if (id) {
|
|
43162
|
-
const existing = await client.get(`/api/persons/${id}`);
|
|
43163
|
-
const personData = existing.data;
|
|
43164
|
-
let current;
|
|
43165
|
-
if (personData.cipher) {
|
|
43166
|
-
current = await decryptPersonCipher(personData.cipher, crypto3.key);
|
|
43167
|
-
} else {
|
|
43168
|
-
current = {
|
|
43169
|
-
name: personData.name,
|
|
43170
|
-
upiHandles: personData.upiHandles,
|
|
43171
|
-
phoneNumbers: personData.phoneNumbers,
|
|
43172
|
-
relationship: personData.relationship,
|
|
43173
|
-
notes: personData.notes
|
|
43174
|
-
};
|
|
43175
|
-
}
|
|
43176
|
-
const merged = {
|
|
43177
|
-
name: updates.name ?? current.name,
|
|
43178
|
-
upiHandles: updates.upiHandles ?? current.upiHandles,
|
|
43179
|
-
phoneNumbers: updates.phoneNumbers ?? current.phoneNumbers,
|
|
43180
|
-
relationship: updates.relationship ?? current.relationship ?? null,
|
|
43181
|
-
notes: updates.notes ?? current.notes ?? null
|
|
43182
|
-
};
|
|
43183
|
-
const payload2 = await encryptPersonCipher(merged, crypto3.key);
|
|
43184
|
-
const data3 = await client.patch(`/api/persons/${id}`, payload2);
|
|
43185
|
-
return { content: [{ type: "text", text: JSON.stringify(data3) }] };
|
|
43186
|
-
}
|
|
43187
|
-
if (!updates.name)
|
|
43188
|
-
throw new Error("name is required when creating a person");
|
|
43189
|
-
const fields = {
|
|
43190
|
-
name: updates.name,
|
|
43191
|
-
upiHandles: updates.upiHandles ?? [],
|
|
43192
|
-
phoneNumbers: updates.phoneNumbers ?? [],
|
|
43193
|
-
relationship: updates.relationship ?? null,
|
|
43194
|
-
notes: updates.notes ?? null
|
|
43195
|
-
};
|
|
43196
|
-
const payload = await encryptPersonCipher(fields, crypto3.key);
|
|
43197
|
-
const data2 = await client.post("/api/persons", payload);
|
|
43198
|
-
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
43199
|
-
}
|
|
43200
|
-
const data = id ? await client.patch(`/api/persons/${id}`, updates) : await client.post("/api/persons", updates);
|
|
43201
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43202
|
-
});
|
|
43203
|
-
server.registerTool("list_persons", {
|
|
43204
|
-
description: "List all persons in the current household. Pass `id` to get a single person by id instead of listing all.",
|
|
43205
|
-
inputSchema: {
|
|
43206
|
-
id: exports_external.string().optional().describe("Person UUID to fetch a single person; omit to list all")
|
|
43207
|
-
}
|
|
43208
|
-
}, async ({ id }) => {
|
|
43209
|
-
if (id) {
|
|
43210
|
-
const data2 = await client.get(`/api/persons/${id}`);
|
|
43211
|
-
if (crypto3 && data2.data.cipher) {
|
|
43212
|
-
const fields = await decryptPersonCipher(data2.data.cipher, crypto3.key);
|
|
43213
|
-
Object.assign(data2.data, fields, { cipher: null });
|
|
43214
|
-
}
|
|
43215
|
-
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
43216
|
-
}
|
|
43217
|
-
const data = await client.get("/api/persons");
|
|
43218
|
-
if (crypto3 && data.data) {
|
|
43219
|
-
for (const person of data.data) {
|
|
43220
|
-
const cipher = person.cipher;
|
|
43221
|
-
if (cipher) {
|
|
43222
|
-
try {
|
|
43223
|
-
const fields = await decryptPersonCipher(cipher, crypto3.key);
|
|
43224
|
-
Object.assign(person, fields, { cipher: null });
|
|
43225
|
-
} catch {}
|
|
43226
|
-
}
|
|
43227
|
-
}
|
|
43711
|
+
}
|
|
43712
|
+
async backOff(tag, attempt, retriesLeft, reason, headers, { retry, signal }) {
|
|
43713
|
+
const delay = retryDelayMs(attempt, headers, retry);
|
|
43714
|
+
const nth = attempt + 1;
|
|
43715
|
+
const total = attempt + retriesLeft;
|
|
43716
|
+
this.logger.info(`${tag} retrying in ${delay}ms (retry ${nth}/${total}) after ${reason}`);
|
|
43717
|
+
try {
|
|
43718
|
+
await sleep(delay, signal);
|
|
43719
|
+
} catch (err) {
|
|
43720
|
+
this.logger.info(`${tag} aborted by caller while waiting to retry`);
|
|
43721
|
+
throw new APIUserAbortError(undefined, { cause: err });
|
|
43228
43722
|
}
|
|
43229
|
-
|
|
43230
|
-
|
|
43231
|
-
|
|
43232
|
-
|
|
43233
|
-
|
|
43234
|
-
|
|
43235
|
-
|
|
43236
|
-
|
|
43237
|
-
|
|
43238
|
-
}
|
|
43239
|
-
|
|
43240
|
-
// src/tools/profile.ts
|
|
43241
|
-
function registerProfileTools(server, client) {
|
|
43242
|
-
server.registerTool("get_profile", {
|
|
43243
|
-
description: "Get the current user's profile (name, email, owner).",
|
|
43244
|
-
inputSchema: {}
|
|
43245
|
-
}, async () => {
|
|
43246
|
-
const data = await client.get("/api/profile");
|
|
43247
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43248
|
-
});
|
|
43249
|
-
server.registerTool("update_profile", {
|
|
43250
|
-
description: "Update your display name.",
|
|
43251
|
-
inputSchema: {
|
|
43252
|
-
name: exports_external.string().min(1).describe("Display name")
|
|
43723
|
+
}
|
|
43724
|
+
};
|
|
43725
|
+
var parseBody = async (res) => {
|
|
43726
|
+
const text = await res.text();
|
|
43727
|
+
if (text.length === 0)
|
|
43728
|
+
return;
|
|
43729
|
+
if ((res.headers.get("content-type") ?? "").includes("application/json"))
|
|
43730
|
+
try {
|
|
43731
|
+
return JSON.parse(text);
|
|
43732
|
+
} catch {
|
|
43733
|
+
return text;
|
|
43253
43734
|
}
|
|
43254
|
-
|
|
43255
|
-
|
|
43256
|
-
|
|
43257
|
-
|
|
43258
|
-
}
|
|
43259
|
-
|
|
43260
|
-
// src/tools/push.ts
|
|
43261
|
-
function registerPushTools(server, client) {
|
|
43262
|
-
server.registerTool("list_push_subscriptions", {
|
|
43263
|
-
description: "List the household's registered web-push subscriptions (browser/device endpoints that receive alert notifications).",
|
|
43264
|
-
inputSchema: {}
|
|
43265
|
-
}, async () => {
|
|
43266
|
-
const data = await client.get("/api/push/subscriptions");
|
|
43267
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43268
|
-
});
|
|
43269
|
-
server.registerTool("send_test_push", {
|
|
43270
|
-
description: "Send a test web-push notification to all of the household's subscriptions. Use to verify push delivery is working.",
|
|
43271
|
-
inputSchema: {}
|
|
43272
|
-
}, async () => {
|
|
43273
|
-
const data = await client.post("/api/push/test");
|
|
43274
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43275
|
-
});
|
|
43276
|
-
}
|
|
43735
|
+
try {
|
|
43736
|
+
return JSON.parse(text);
|
|
43737
|
+
} catch {
|
|
43738
|
+
return text;
|
|
43739
|
+
}
|
|
43740
|
+
};
|
|
43277
43741
|
|
|
43278
|
-
// src/
|
|
43279
|
-
function
|
|
43280
|
-
|
|
43281
|
-
|
|
43282
|
-
|
|
43283
|
-
|
|
43284
|
-
|
|
43285
|
-
|
|
43286
|
-
|
|
43287
|
-
|
|
43288
|
-
|
|
43289
|
-
|
|
43290
|
-
|
|
43291
|
-
|
|
43292
|
-
type: exports_external.enum(["debit", "credit"]),
|
|
43293
|
-
frequency: exports_external.enum(["daily", "weekly", "monthly", "yearly"]),
|
|
43294
|
-
dayOfMonth: exports_external.number().optional().describe("For monthly — day of month (1-31)"),
|
|
43295
|
-
categorySlug: exports_external.string().optional(),
|
|
43296
|
-
owner: exports_external.string(),
|
|
43297
|
-
startDate: exports_external.string().optional().describe("YYYY-MM-DD"),
|
|
43298
|
-
notes: exports_external.string().optional()
|
|
43742
|
+
// src/lib/categories.ts
|
|
43743
|
+
async function loadCategoryIndex(client) {
|
|
43744
|
+
const res = await client.get("/api/categories");
|
|
43745
|
+
const bySlug = new Map;
|
|
43746
|
+
const byId = new Map;
|
|
43747
|
+
const parents = new Set;
|
|
43748
|
+
for (const root of res.data ?? []) {
|
|
43749
|
+
if (root.slug && (root.children?.length ?? 0) > 0)
|
|
43750
|
+
parents.add(root.slug);
|
|
43751
|
+
for (const c of [root, ...root.children ?? []]) {
|
|
43752
|
+
if (!c.slug)
|
|
43753
|
+
continue;
|
|
43754
|
+
bySlug.set(c.slug, { id: c.id, type: c.type });
|
|
43755
|
+
byId.set(c.id, { slug: c.slug, type: c.type });
|
|
43299
43756
|
}
|
|
43300
|
-
}
|
|
43301
|
-
|
|
43302
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43303
|
-
});
|
|
43757
|
+
}
|
|
43758
|
+
return { bySlug, byId, parents };
|
|
43304
43759
|
}
|
|
43305
|
-
|
|
43306
|
-
|
|
43307
|
-
|
|
43308
|
-
|
|
43309
|
-
|
|
43310
|
-
|
|
43311
|
-
month: exports_external.number().min(1).max(12),
|
|
43312
|
-
year: exports_external.number().min(2020).max(2100)
|
|
43313
|
-
}
|
|
43314
|
-
}, async (body) => {
|
|
43315
|
-
const data = await client.post("/api/reports", body);
|
|
43316
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43317
|
-
});
|
|
43318
|
-
server.registerTool("get_reports", {
|
|
43319
|
-
description: "Monthly financial reports for the last N months — income, expenses, savings rate, net worth.",
|
|
43320
|
-
inputSchema: {
|
|
43321
|
-
months: exports_external.number().optional().describe("Number of months (default: 6)")
|
|
43322
|
-
}
|
|
43323
|
-
}, async (params) => {
|
|
43324
|
-
const data = await client.get("/api/reports", params.months ? { months: params.months } : undefined);
|
|
43325
|
-
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
43326
|
-
});
|
|
43760
|
+
function requireCategory(index, slug) {
|
|
43761
|
+
const hit = index.bySlug.get(slug);
|
|
43762
|
+
if (!hit) {
|
|
43763
|
+
throw new Error(`Unknown categorySlug "${slug}". Valid: ${[...index.bySlug.keys()].sort().join(", ")}`);
|
|
43764
|
+
}
|
|
43765
|
+
return { ...hit, slug };
|
|
43327
43766
|
}
|
|
43328
43767
|
|
|
43329
|
-
// src/tools/transactions.ts
|
|
43330
|
-
import { readFile } from "node:fs/promises";
|
|
43331
|
-
|
|
43332
43768
|
// ../reconciliation/src/aliases.ts
|
|
43333
43769
|
var MERCHANT_ALIASES = [
|
|
43334
43770
|
["Swiggy", ["swiggy", "bundl technologies", "swiggy bundl"]],
|
|
@@ -43469,6 +43905,13 @@ function resolveAlias(name) {
|
|
|
43469
43905
|
}
|
|
43470
43906
|
return null;
|
|
43471
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
|
+
}
|
|
43472
43915
|
// ../../node_modules/.bun/fuzzball@2.2.3/node_modules/fuzzball/dist/esm/fuzzball.esm.min.js
|
|
43473
43916
|
var e;
|
|
43474
43917
|
var t = typeof globalThis != "undefined" ? globalThis : typeof window != "undefined" ? window : typeof global != "undefined" ? global : typeof self != "undefined" ? self : {};
|
|
@@ -43520,9 +43963,9 @@ var i = function() {
|
|
|
43520
43963
|
return _has(u, e3);
|
|
43521
43964
|
};
|
|
43522
43965
|
}, SequenceMatcher.prototype.findLongestMatch = function(e2, t2, r2, n2) {
|
|
43523
|
-
var o2, a2, i2, s, c, u, l, f, p, h, v, _, d, y,
|
|
43966
|
+
var o2, a2, i2, s, c, u, l, f, p, h, v, _, d, y, g2, m, b, k, j, $, w;
|
|
43524
43967
|
for (o2 = (m = [this.a, this.b, this.b2j, this.isbjunk])[0], a2 = m[1], i2 = m[2], f = m[3], s = (b = [e2, r2, 0])[0], c = b[1], u = b[2], h = {}, l = d = e2;e2 <= t2 ? d < t2 : d > t2; l = e2 <= t2 ? ++d : --d) {
|
|
43525
|
-
for (_ = {}, y = 0,
|
|
43968
|
+
for (_ = {}, y = 0, g2 = (k = _has(i2, o2[l]) ? i2[o2[l]] : []).length;y < g2; y++)
|
|
43526
43969
|
if (!((p = k[y]) < r2)) {
|
|
43527
43970
|
if (p >= n2)
|
|
43528
43971
|
break;
|
|
@@ -43540,11 +43983,11 @@ var i = function() {
|
|
|
43540
43983
|
u++;
|
|
43541
43984
|
return [s, c, u];
|
|
43542
43985
|
}, SequenceMatcher.prototype.getMatchingBlocks = function() {
|
|
43543
|
-
var e2, t2, r2, n2, o2, a2, i2, s, c, u, l, f, p, h, v, _, d, y,
|
|
43986
|
+
var e2, t2, r2, n2, o2, a2, i2, s, c, u, l, f, p, h, v, _, d, y, g2, m, b, k, j, $, w, O;
|
|
43544
43987
|
if (this.matchingBlocks)
|
|
43545
43988
|
return this.matchingBlocks;
|
|
43546
43989
|
for (y = [[0, h = (k = [this.a.length, this.b.length])[0], 0, v = k[1]]], _ = [];y.length; )
|
|
43547
|
-
t2 = (j = y.pop())[0], e2 = j[1], n2 = j[2], r2 = j[3], o2 = ($ =
|
|
43990
|
+
t2 = (j = y.pop())[0], e2 = j[1], n2 = j[2], r2 = j[3], o2 = ($ = g2 = this.findLongestMatch(t2, e2, n2, r2))[0], s = $[1], (l = $[2]) && (_.push(g2), t2 < o2 && n2 < s && y.push([t2, o2, n2, s]), o2 + l < e2 && s + l < r2 && y.push([o2 + l, e2, s + l, r2]));
|
|
43548
43991
|
for (_.sort(_arrayCmp), a2 = c = f = 0, d = [], m = 0, b = _.length;m < b; m++)
|
|
43549
43992
|
i2 = (w = _[m])[0], u = w[1], p = w[2], a2 + f === i2 && c + f === u ? f += p : (f && d.push([a2, c, f]), a2 = (O = [i2, u, p])[0], c = O[1], f = O[2]);
|
|
43550
43993
|
return f && d.push([a2, c, f]), d.push([h, v, 0]), this.matchingBlocks = d;
|
|
@@ -43692,11 +44135,11 @@ var v = p || h || Function("return this")();
|
|
|
43692
44135
|
var _ = v.Symbol;
|
|
43693
44136
|
var d = _;
|
|
43694
44137
|
var y = Object.prototype;
|
|
43695
|
-
var
|
|
44138
|
+
var g2 = y.hasOwnProperty;
|
|
43696
44139
|
var m = y.toString;
|
|
43697
44140
|
var b = d ? d.toStringTag : undefined;
|
|
43698
44141
|
var k = function getRawTag$1(e2) {
|
|
43699
|
-
var t2 =
|
|
44142
|
+
var t2 = g2.call(e2, b), r2 = e2[b];
|
|
43700
44143
|
try {
|
|
43701
44144
|
e2[b] = undefined;
|
|
43702
44145
|
var n2 = true;
|
|
@@ -44283,8 +44726,8 @@ var Xr = function equalObjects$1(e2, t2, r2, n2, o2, a2) {
|
|
|
44283
44726
|
v2 || (v2 = l2 == "constructor");
|
|
44284
44727
|
}
|
|
44285
44728
|
if (h2 && !v2) {
|
|
44286
|
-
var
|
|
44287
|
-
|
|
44729
|
+
var g3 = e2.constructor, m2 = t2.constructor;
|
|
44730
|
+
g3 == m2 || !("constructor" in e2) || !("constructor" in t2) || typeof g3 == "function" && g3 instanceof g3 && typeof m2 == "function" && m2 instanceof m2 || (h2 = false);
|
|
44288
44731
|
}
|
|
44289
44732
|
return a2.delete(e2), a2.delete(t2), h2;
|
|
44290
44733
|
};
|
|
@@ -44654,15 +45097,15 @@ var qo = function leven2(e2, t2, r2, n2) {
|
|
|
44654
45097
|
return 0;
|
|
44655
45098
|
} else
|
|
44656
45099
|
f2 = (l2 = r2.wildcards[0].toLowerCase()).charCodeAt(0);
|
|
44657
|
-
for (var y2 = 0,
|
|
45100
|
+
for (var y2 = 0, g3 = 0;y2 < c2; )
|
|
44658
45101
|
a2[y2] = e2.charCodeAt(y2), o2[y2] = ++y2;
|
|
44659
45102
|
if (i2)
|
|
44660
|
-
for (;
|
|
44661
|
-
for (p2 = t2.charCodeAt(
|
|
45103
|
+
for (;g3 < u2; )
|
|
45104
|
+
for (p2 = t2.charCodeAt(g3), v2 = g3++, h2 = g3, y2 = 0;y2 < c2; y2++)
|
|
44662
45105
|
_2 = Io.compare(String.fromCharCode(p2), String.fromCharCode(a2[y2])) === 0 || p2 === f2 || a2[y2] === f2 ? v2 : v2 + s2, v2 = o2[y2], h2 = o2[y2] = v2 > h2 ? _2 > h2 ? h2 + 1 : _2 : _2 > v2 ? v2 + 1 : _2;
|
|
44663
45106
|
else
|
|
44664
|
-
for (;
|
|
44665
|
-
for (p2 = t2.charCodeAt(
|
|
45107
|
+
for (;g3 < u2; )
|
|
45108
|
+
for (p2 = t2.charCodeAt(g3), v2 = g3++, h2 = g3, y2 = 0;y2 < c2; y2++)
|
|
44666
45109
|
_2 = p2 === a2[y2] || p2 === f2 || a2[y2] === f2 ? v2 : v2 + s2, v2 = o2[y2], h2 = o2[y2] = v2 > h2 ? _2 > h2 ? h2 + 1 : _2 : _2 > v2 ? v2 + 1 : _2;
|
|
44667
45110
|
return h2;
|
|
44668
45111
|
}
|
|
@@ -44867,7 +45310,7 @@ var Lo = { exports: {} };
|
|
|
44867
45310
|
}, n3.isCustomFunc = function(e4) {
|
|
44868
45311
|
return typeof e4 != "function" || e4.name !== "token_set_ratio" && e4.name !== "partial_token_set_ratio" && e4.name !== "token_sort_ratio" && e4.name !== "partial_token_sort_ratio" && e4.name !== "QRatio" && e4.name !== "WRatio" && e4.name !== "distance" && e4.name !== "partial_ratio";
|
|
44869
45312
|
}, n3;
|
|
44870
|
-
}(Mo._uniq, Mo._uniqWith, l2), y2 = d2.validate,
|
|
45313
|
+
}(Mo._uniq, Mo._uniqWith, l2), y2 = d2.validate, g3 = d2.process_and_sort, m2 = d2.tokenize, b2 = d2.full_process, k2 = d2.clone_and_set_option_defaults, j2 = d2.isCustomFunc, $2 = Lo.exports(k2, p2, QRatio, extract).dedupe;
|
|
44871
45314
|
function QRatio(e3, t3, r2) {
|
|
44872
45315
|
var n3 = k2(r2);
|
|
44873
45316
|
return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? _ratio(e3, t3, n3) : 0;
|
|
@@ -44882,11 +45325,11 @@ var Lo = { exports: {} };
|
|
|
44882
45325
|
}
|
|
44883
45326
|
function token_sort_ratio(e3, t3, r2) {
|
|
44884
45327
|
var n3 = k2(r2);
|
|
44885
|
-
return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? (n3.proc_sorted || (e3 =
|
|
45328
|
+
return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? (n3.proc_sorted || (e3 = g3(e3), t3 = g3(t3)), _ratio(e3, t3, n3)) : 0;
|
|
44886
45329
|
}
|
|
44887
45330
|
function partial_token_sort_ratio(e3, t3, r2) {
|
|
44888
45331
|
var n3 = k2(r2);
|
|
44889
|
-
return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? (n3.partial = true, n3.proc_sorted || (e3 =
|
|
45332
|
+
return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? (n3.partial = true, n3.proc_sorted || (e3 = g3(e3), t3 = g3(t3)), _partial_ratio(e3, t3, n3)) : 0;
|
|
44890
45333
|
}
|
|
44891
45334
|
function extract(e3, r2, n3) {
|
|
44892
45335
|
var o3, a3 = k2(n3);
|
|
@@ -44916,7 +45359,7 @@ var Lo = { exports: {} };
|
|
|
44916
45359
|
i2 || (a3.astral && a3.normalize && (a3.normalize = false, String.prototype.normalize ? (s3 = true, e3 = e3.normalize()) : typeof console !== undefined && console.warn("Normalization not supported in your environment")), e3 = pre_processor(e3, a3), a3.full_process = false, e3.length === 0 && typeof console !== undefined && console.warn("Processed query is empty string"));
|
|
44917
45360
|
var c2, u3, l3, h3, v3 = [], _3 = false, d3 = false, y3 = false;
|
|
44918
45361
|
if (a3.scorer.name === "token_sort_ratio" || a3.scorer.name === "partial_token_sort_ratio") {
|
|
44919
|
-
var $3 =
|
|
45362
|
+
var $3 = g3(e3);
|
|
44920
45363
|
d3 = true;
|
|
44921
45364
|
} else if (a3.scorer.name === "token_set_ratio" || a3.scorer.name === "partial_token_set_ratio") {
|
|
44922
45365
|
var w3 = m2(e3, a3);
|
|
@@ -44931,7 +45374,7 @@ var Lo = { exports: {} };
|
|
|
44931
45374
|
}, h3 = function(e4, t3) {
|
|
44932
45375
|
return t3[1] - e4[1];
|
|
44933
45376
|
}), f2(r2, function(t3, r3) {
|
|
44934
|
-
a3.tokens = undefined, a3.proc_sorted = false, d3 ? (a3.proc_sorted = true, t3 && t3.proc_sorted ? u3 = t3.proc_sorted : (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3), u3 =
|
|
45377
|
+
a3.tokens = undefined, a3.proc_sorted = false, d3 ? (a3.proc_sorted = true, t3 && t3.proc_sorted ? u3 = t3.proc_sorted : (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3), u3 = g3(u3)), c2 = a3.scorer($3, u3, a3)) : y3 ? (u3 = "x", t3 && t3.tokens ? (a3.tokens = [w3, t3.tokens], a3.trySimple && (u3 = pre_processor(a3.processor(t3), a3))) : (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3), a3.tokens = [w3, m2(u3, a3)]), c2 = a3.scorer(e3, u3, a3)) : i2 ? (u3 = a3.processor(t3), c2 = a3.scorer(e3, u3, a3)) : (typeof (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3)) == "string" && u3.length !== 0 || (_3 = true), c2 = a3.scorer(e3, u3, a3)), c2 > a3.cutoff && (a3.returnObjects ? v3.push({ choice: t3, score: c2, key: r3 }) : v3.push([t3, c2, r3]));
|
|
44935
45378
|
}), _3 && typeof console !== undefined && console.log("One or more choices were empty. (post-processing if applied)"), a3.limit && typeof a3.limit == "number" && a3.limit > 0 && a3.limit < o3 && !a3.unsorted ? v3 = t2.nlargest(v3, a3.limit, l3) : a3.unsorted || (v3 = v3.sort(h3)), v3;
|
|
44936
45379
|
}
|
|
44937
45380
|
function extractAsync(e3, r2, n3, o3) {
|
|
@@ -44967,7 +45410,7 @@ var Lo = { exports: {} };
|
|
|
44967
45410
|
f3 || (s3.astral && s3.normalize && (s3.normalize = false, String.prototype.normalize ? (h3 = true, e3 = e3.normalize()) : typeof console !== undefined && console.warn("Normalization not supported in your environment")), e3 = pre_processor(e3, s3), s3.full_process = false, e3.length === 0 && typeof console !== undefined && console.warn("Processed query is empty string"));
|
|
44968
45411
|
var v3, _3, d3, y3, $3, w3 = [], O3 = false, S3 = false, C2 = false;
|
|
44969
45412
|
if (s3.scorer.name === "token_sort_ratio" || s3.scorer.name === "partial_token_sort_ratio") {
|
|
44970
|
-
var z2 =
|
|
45413
|
+
var z2 = g3(e3);
|
|
44971
45414
|
S3 = true;
|
|
44972
45415
|
} else if (s3.scorer.name === "token_set_ratio" || s3.scorer.name === "partial_token_set_ratio") {
|
|
44973
45416
|
var A2 = m2(e3, s3);
|
|
@@ -44986,7 +45429,7 @@ var Lo = { exports: {} };
|
|
|
44986
45429
|
l3 ? searchLoop(0) : searchLoop(x2[0], 0);
|
|
44987
45430
|
}
|
|
44988
45431
|
function searchLoop(n4, p3) {
|
|
44989
|
-
(l3 || r2.hasOwnProperty(n4)) && (s3.tokens = undefined, s3.proc_sorted = false, S3 ? (s3.proc_sorted = true, r2[n4] && r2[n4].proc_sorted ? _3 = r2[n4].proc_sorted : (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3), _3 =
|
|
45432
|
+
(l3 || r2.hasOwnProperty(n4)) && (s3.tokens = undefined, s3.proc_sorted = false, S3 ? (s3.proc_sorted = true, r2[n4] && r2[n4].proc_sorted ? _3 = r2[n4].proc_sorted : (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3), _3 = g3(_3)), d3 = s3.scorer(z2, _3, s3)) : C2 ? (_3 = "x", r2[n4] && r2[n4].tokens ? (s3.tokens = [A2, r2[n4].tokens], s3.trySimple && (_3 = pre_processor(s3.processor(r2[n4]), s3))) : (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3), s3.tokens = [A2, m2(_3, s3)]), d3 = s3.scorer(e3, _3, s3)) : f3 ? (_3 = s3.processor(r2[n4]), d3 = s3.scorer(e3, _3, s3)) : (typeof (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3)) == "string" && _3.length !== 0 || (O3 = true), d3 = s3.scorer(e3, _3, s3)), v3 = l3 ? parseInt(n4) : n4, d3 > s3.cutoff && (s3.returnObjects ? w3.push({ choice: r2[n4], score: d3, key: v3 }) : w3.push([r2[n4], d3, v3]))), a3 && a3.signal.aborted === true ? o3(new Error("aborted")) : i2 && i2.canceled === true ? o3(new Error("canceled")) : l3 && n4 < r2.length - 1 ? n4 % c2 == 0 ? setImmediate(function() {
|
|
44990
45433
|
searchLoop(n4 + 1);
|
|
44991
45434
|
}) : searchLoop(n4 + 1) : p3 < x2.length - 1 ? p3 % c2 == 0 ? setImmediate(function() {
|
|
44992
45435
|
searchLoop(x2[p3 + 1], p3 + 1);
|
|
@@ -45072,11 +45515,11 @@ var Lo = { exports: {} };
|
|
|
45072
45515
|
}, f3 = a2(n3, i2, wildCompare), p3 = u2(n3, i2, wildCompare), h3 = u2(i2, n3, wildCompare);
|
|
45073
45516
|
else
|
|
45074
45517
|
f3 = o2(n3, i2), p3 = s2(n3, i2), h3 = s2(i2, n3);
|
|
45075
|
-
var d3 = f3.sort().join(" "), y3 = p3.sort(),
|
|
45518
|
+
var d3 = f3.sort().join(" "), y3 = p3.sort(), g4 = h3.sort();
|
|
45076
45519
|
if (r2.sortBySimilarity)
|
|
45077
|
-
var b3 = _order_token_lists(e3, y3, t3,
|
|
45520
|
+
var b3 = _order_token_lists(e3, y3, t3, g4), k3 = b3[0], j3 = b3[1], $3 = k3.join(" "), w3 = _token_similarity_sort(k3, j3, r2).join(" ");
|
|
45078
45521
|
else
|
|
45079
|
-
$3 = y3.join(" "), w3 =
|
|
45522
|
+
$3 = y3.join(" "), w3 = g4.join(" ");
|
|
45080
45523
|
var O3 = d3 + " " + $3, S3 = d3 + " " + w3;
|
|
45081
45524
|
d3 = d3.trim(), O3 = O3.trim(), S3 = S3.trim();
|
|
45082
45525
|
var C2 = _ratio;
|
|
@@ -45161,7 +45604,7 @@ var Lo = { exports: {} };
|
|
|
45161
45604
|
}
|
|
45162
45605
|
var p3 = token_sort_ratio(e3, t3, n3) * a3, h3 = token_set_ratio(e3, t3, n3) * a3;
|
|
45163
45606
|
return Math.round(Math.max(s3, p3, h3));
|
|
45164
|
-
}, full_process: b2, extract, extractAsync, extractAsPromised: O2, process_and_sort:
|
|
45607
|
+
}, full_process: b2, extract, extractAsync, extractAsPromised: O2, process_and_sort: g3, unique_tokens: m2, dedupe: $2 };
|
|
45165
45608
|
r.exports = S2;
|
|
45166
45609
|
}();
|
|
45167
45610
|
var Ro = r.exports;
|
|
@@ -45185,9 +45628,35 @@ var ta = Ro.dedupe;
|
|
|
45185
45628
|
|
|
45186
45629
|
// ../reconciliation/src/normalise.ts
|
|
45187
45630
|
var CORPORATE_SUFFIXES = /\b(PRIVATE|PVT\.?|LIMITED|LTD\.?)\s*\w*/gi;
|
|
45631
|
+
var TOKEN_SET_THRESHOLD = 80;
|
|
45188
45632
|
function normaliseName(name) {
|
|
45189
45633
|
return name.replace(CORPORATE_SUFFIXES, "").replace(/[^\w\s]/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
|
|
45190
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
|
+
}
|
|
45191
45660
|
|
|
45192
45661
|
// ../reconciliation/src/entity.ts
|
|
45193
45662
|
var FUZZBALL_THRESHOLD = 92;
|
|
@@ -45329,6 +45798,151 @@ function entityRule(e2, bag) {
|
|
|
45329
45798
|
return bag.merchantRules[e2.merchantId] ?? null;
|
|
45330
45799
|
return bag.personRules[e2.personId] ?? null;
|
|
45331
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
|
+
}
|
|
45332
45946
|
// ../reconciliation/src/settlement.ts
|
|
45333
45947
|
var SETTLEMENT_PATTERNS = [
|
|
45334
45948
|
/\bCRED\b.*\bPAYMENT\b/i,
|
|
@@ -45405,20 +46019,344 @@ function settlementEnrichedData(description, type, source) {
|
|
|
45405
46019
|
out.settlementCardLast4 = last4;
|
|
45406
46020
|
return out;
|
|
45407
46021
|
}
|
|
45408
|
-
|
|
45409
|
-
|
|
45410
|
-
"
|
|
45411
|
-
|
|
45412
|
-
|
|
45413
|
-
|
|
45414
|
-
|
|
45415
|
-
|
|
45416
|
-
|
|
45417
|
-
|
|
45418
|
-
|
|
45419
|
-
|
|
45420
|
-
|
|
45421
|
-
|
|
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
|
+
}
|
|
46077
|
+
// src/lib/link-batches.ts
|
|
46078
|
+
var MAX_LINKS = 500;
|
|
46079
|
+
var MAX_TARGETS = 25;
|
|
46080
|
+
function chunkLinks(links) {
|
|
46081
|
+
const batches = [];
|
|
46082
|
+
let cur = [];
|
|
46083
|
+
let targets = new Set;
|
|
46084
|
+
for (const l2 of links) {
|
|
46085
|
+
const key = `${l2.merchantId ?? ""}|${l2.personId ?? ""}|${l2.categoryId ?? ""}`;
|
|
46086
|
+
if (cur.length >= MAX_LINKS || !targets.has(key) && targets.size >= MAX_TARGETS) {
|
|
46087
|
+
batches.push(cur);
|
|
46088
|
+
cur = [];
|
|
46089
|
+
targets = new Set;
|
|
46090
|
+
}
|
|
46091
|
+
cur.push(l2);
|
|
46092
|
+
targets.add(key);
|
|
46093
|
+
}
|
|
46094
|
+
if (cur.length)
|
|
46095
|
+
batches.push(cur);
|
|
46096
|
+
return batches;
|
|
46097
|
+
}
|
|
46098
|
+
async function postLinks(client, links) {
|
|
46099
|
+
for (const batch of chunkLinks(links)) {
|
|
46100
|
+
await client.post("/api/transactions/bulk-link-entity", { links: batch });
|
|
46101
|
+
}
|
|
46102
|
+
}
|
|
46103
|
+
|
|
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;
|
|
46115
|
+
}
|
|
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
|
+
}
|
|
46170
|
+
}
|
|
46171
|
+
return result;
|
|
46172
|
+
}
|
|
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
|
+
}
|
|
46223
|
+
}
|
|
46224
|
+
return [...buckets.values()].map((row) => row.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" "));
|
|
46225
|
+
}
|
|
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)
|
|
46233
|
+
continue;
|
|
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
|
+
});
|
|
46265
|
+
}
|
|
46266
|
+
return transactions;
|
|
46267
|
+
}
|
|
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) };
|
|
46331
|
+
}
|
|
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")}`;
|
|
46338
|
+
}
|
|
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);
|
|
46345
|
+
}
|
|
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
|
+
];
|
|
45422
46360
|
var CORPORATE_REGEX = new RegExp(`\\b(${CORPORATE_TOKENS.join("|")})\\b`);
|
|
45423
46361
|
function classifyEntity({ paymentMethod, extractedName }) {
|
|
45424
46362
|
if (paymentMethod === "atm")
|
|
@@ -45438,6 +46376,17 @@ function classifyEntity({ paymentMethod, extractedName }) {
|
|
|
45438
46376
|
return "merchant";
|
|
45439
46377
|
return "unknown";
|
|
45440
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
|
+
|
|
45441
46390
|
// ../parsers/src/hdfc/narration.ts
|
|
45442
46391
|
function parseHdfcNarration(narration) {
|
|
45443
46392
|
const trimmed = narration.trim();
|
|
@@ -45626,6 +46575,796 @@ function parseSbiNarration(narration) {
|
|
|
45626
46575
|
}
|
|
45627
46576
|
return { paymentMethod: null, name: null, upiId: null };
|
|
45628
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
|
+
});
|
|
46631
|
+
}
|
|
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;
|
|
46698
|
+
}
|
|
46699
|
+
}
|
|
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
|
+
});
|
|
46805
|
+
}
|
|
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
|
|
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
|
+
}
|
|
46955
|
+
}
|
|
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
|
+
}
|
|
46977
|
+
}
|
|
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);
|
|
46985
|
+
}
|
|
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));
|
|
46992
|
+
}
|
|
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
|
+
});
|
|
47109
|
+
}
|
|
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]);
|
|
47151
|
+
} else {
|
|
47152
|
+
const existing = buckets.get(bucketKey);
|
|
47153
|
+
if (existing)
|
|
47154
|
+
existing.push(item);
|
|
47155
|
+
}
|
|
47156
|
+
}
|
|
47157
|
+
return [...buckets.values()].map((row) => row.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" "));
|
|
47158
|
+
}
|
|
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 });
|
|
47167
|
+
}
|
|
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}`;
|
|
47201
|
+
}
|
|
47202
|
+
}
|
|
47203
|
+
if (bestKey) {
|
|
47204
|
+
const bucket = buckets.get(bestKey);
|
|
47205
|
+
if (bucket)
|
|
47206
|
+
bucket.narrationParts.push(item.str);
|
|
47207
|
+
}
|
|
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;
|
|
47230
|
+
}
|
|
47231
|
+
}
|
|
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);
|
|
47239
|
+
}
|
|
47240
|
+
}
|
|
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));
|
|
47246
|
+
}
|
|
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;
|
|
47267
|
+
return {
|
|
47268
|
+
date: date4,
|
|
47269
|
+
description: narration,
|
|
47270
|
+
amount,
|
|
47271
|
+
type,
|
|
47272
|
+
referenceNumber,
|
|
47273
|
+
valueDate,
|
|
47274
|
+
closingBalance,
|
|
47275
|
+
paymentMethod: parsed.paymentMethod,
|
|
47276
|
+
extractedName: parsed.name,
|
|
47277
|
+
upiId: parsed.upiId,
|
|
47278
|
+
sourceCategory: null
|
|
47279
|
+
};
|
|
47280
|
+
}
|
|
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]}`;
|
|
47288
|
+
}
|
|
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);
|
|
47295
|
+
}
|
|
47296
|
+
// src/lib/txn-rows.ts
|
|
47297
|
+
function toTxnRow(r2) {
|
|
47298
|
+
return {
|
|
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
|
|
47310
|
+
};
|
|
47311
|
+
}
|
|
47312
|
+
async function fetchAllRows(client, crypto3, params = {}, maxPages = 100) {
|
|
47313
|
+
return (await fetchAllRowsPaged(client, crypto3, params, maxPages)).rows;
|
|
47314
|
+
}
|
|
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 ?? "");
|
|
47355
|
+
return {
|
|
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 } : {}
|
|
47365
|
+
};
|
|
47366
|
+
}
|
|
47367
|
+
|
|
45629
47368
|
// src/lib/person-backfill.ts
|
|
45630
47369
|
function normalizePersonName(name) {
|
|
45631
47370
|
return name.toLowerCase().trim().replace(/\s+/g, " ");
|
|
@@ -45640,141 +47379,792 @@ async function loadDecryptedPersons(client, crypto3) {
|
|
|
45640
47379
|
name = (await decryptPersonCipher(p2.cipher, crypto3.key)).name;
|
|
45641
47380
|
} catch {}
|
|
45642
47381
|
}
|
|
45643
|
-
if (name)
|
|
45644
|
-
persons.push({ id: p2.id, name });
|
|
45645
|
-
}
|
|
45646
|
-
return persons;
|
|
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 };
|
|
47410
|
+
return {
|
|
47411
|
+
name: hdfc.name || sbi.name,
|
|
47412
|
+
upiId: hdfc.upiId ?? sbi.upiId,
|
|
47413
|
+
paymentMethod: hdfc.paymentMethod ?? sbi.paymentMethod
|
|
47414
|
+
};
|
|
47415
|
+
}
|
|
47416
|
+
function isPersonLikeNarration(description) {
|
|
47417
|
+
const { name, paymentMethod } = parseNarration(description);
|
|
47418
|
+
return classifyEntity({ paymentMethod, extractedName: name }) !== "merchant";
|
|
47419
|
+
}
|
|
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: []
|
|
47441
|
+
};
|
|
47442
|
+
if (upiId)
|
|
47443
|
+
g3.upiHandles.add(upiId);
|
|
47444
|
+
g3.txnIds.push(t2.id);
|
|
47445
|
+
groups.set(norm, g3);
|
|
47446
|
+
}
|
|
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 });
|
|
47464
|
+
}
|
|
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
|
+
});
|
|
47482
|
+
}
|
|
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
|
+
}
|
|
47493
|
+
}
|
|
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
|
+
}
|
|
47499
|
+
}
|
|
47500
|
+
return {
|
|
47501
|
+
dryRun: !!opts.dryRun,
|
|
47502
|
+
truncated,
|
|
47503
|
+
candidatesScanned: candidates.length,
|
|
47504
|
+
created,
|
|
47505
|
+
reused,
|
|
47506
|
+
linked: links.length,
|
|
47507
|
+
plan
|
|
47508
|
+
};
|
|
47509
|
+
}
|
|
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
|
+
});
|
|
48081
|
+
}
|
|
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")
|
|
48096
|
+
}
|
|
48097
|
+
}, async (body) => {
|
|
48098
|
+
const data = await client.patch("/api/profile", body);
|
|
48099
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48100
|
+
});
|
|
45647
48101
|
}
|
|
45648
|
-
|
|
45649
|
-
|
|
45650
|
-
|
|
45651
|
-
|
|
45652
|
-
|
|
45653
|
-
|
|
45654
|
-
|
|
45655
|
-
|
|
45656
|
-
return {
|
|
45657
|
-
|
|
45658
|
-
|
|
45659
|
-
|
|
45660
|
-
|
|
45661
|
-
|
|
45662
|
-
|
|
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
|
+
});
|
|
45663
48119
|
}
|
|
45664
|
-
|
|
45665
|
-
|
|
45666
|
-
|
|
45667
|
-
|
|
45668
|
-
|
|
45669
|
-
|
|
45670
|
-
|
|
45671
|
-
|
|
45672
|
-
|
|
45673
|
-
|
|
45674
|
-
|
|
45675
|
-
|
|
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
|
+
});
|
|
45676
48137
|
}
|
|
45677
|
-
|
|
45678
|
-
|
|
45679
|
-
|
|
45680
|
-
|
|
45681
|
-
|
|
45682
|
-
|
|
45683
|
-
|
|
45684
|
-
|
|
45685
|
-
for (let page = 0;page < MAX_PAGES; page++) {
|
|
45686
|
-
const params = { limit: 200 };
|
|
45687
|
-
if (cursor)
|
|
45688
|
-
params.cursor = cursor;
|
|
45689
|
-
const res = await client.get("/api/transactions", params);
|
|
45690
|
-
const rows = res.data ?? [];
|
|
45691
|
-
if (crypto3)
|
|
45692
|
-
await decryptTransactionFields(rows, crypto3.key);
|
|
45693
|
-
for (const r2 of rows) {
|
|
45694
|
-
if (!r2.merchantId && !r2.personId && !r2.isTransfer && !r2.isIgnored)
|
|
45695
|
-
candidates.push(r2);
|
|
45696
|
-
}
|
|
45697
|
-
if (!res.hasMore || !res.nextCursor || rows.length === 0)
|
|
45698
|
-
break;
|
|
45699
|
-
cursor = res.nextCursor;
|
|
45700
|
-
}
|
|
45701
|
-
const groups = new Map;
|
|
45702
|
-
for (const t2 of candidates) {
|
|
45703
|
-
const { name, upiId, paymentMethod } = parseNarration(t2.description);
|
|
45704
|
-
if (!name)
|
|
45705
|
-
continue;
|
|
45706
|
-
if (classifyEntity({ paymentMethod, extractedName: name }) !== "person")
|
|
45707
|
-
continue;
|
|
45708
|
-
const norm = normalizePersonName(name);
|
|
45709
|
-
if (!norm)
|
|
45710
|
-
continue;
|
|
45711
|
-
const g2 = groups.get(norm) ?? {
|
|
45712
|
-
displayName: name.trim(),
|
|
45713
|
-
upiHandles: new Set,
|
|
45714
|
-
txnIds: []
|
|
45715
|
-
};
|
|
45716
|
-
if (upiId)
|
|
45717
|
-
g2.upiHandles.add(upiId);
|
|
45718
|
-
g2.txnIds.push(t2.id);
|
|
45719
|
-
groups.set(norm, g2);
|
|
45720
|
-
}
|
|
45721
|
-
const plan = [];
|
|
45722
|
-
const newGroups = [];
|
|
45723
|
-
let reused = 0;
|
|
45724
|
-
for (const [norm, g2] of groups) {
|
|
45725
|
-
const upiHandles = [...g2.upiHandles];
|
|
45726
|
-
const isReuse = personIdByNorm.has(norm);
|
|
45727
|
-
plan.push({
|
|
45728
|
-
name: g2.displayName,
|
|
45729
|
-
normalized: norm,
|
|
45730
|
-
txnCount: g2.txnIds.length,
|
|
45731
|
-
upiHandles,
|
|
45732
|
-
action: isReuse ? "reuse" : "create"
|
|
45733
|
-
});
|
|
45734
|
-
if (isReuse)
|
|
45735
|
-
reused++;
|
|
45736
|
-
else
|
|
45737
|
-
newGroups.push({ norm, displayName: g2.displayName, upiHandles });
|
|
45738
|
-
}
|
|
45739
|
-
if (!opts.dryRun && newGroups.length > 0) {
|
|
45740
|
-
const bodies = await Promise.all(newGroups.map(async ({ displayName, upiHandles }) => {
|
|
45741
|
-
const fields = {
|
|
45742
|
-
name: displayName,
|
|
45743
|
-
upiHandles,
|
|
45744
|
-
phoneNumbers: [],
|
|
45745
|
-
relationship: null,
|
|
45746
|
-
notes: null
|
|
45747
|
-
};
|
|
45748
|
-
return crypto3 ? await encryptPersonCipher(fields, crypto3.key) : { name: displayName, upiHandles, phoneNumbers: [], relationship: null, notes: null };
|
|
45749
|
-
}));
|
|
45750
|
-
const res = await client.post("/api/persons/bulk", {
|
|
45751
|
-
persons: bodies
|
|
45752
|
-
});
|
|
45753
|
-
res.data.forEach((p2, i2) => {
|
|
45754
|
-
personIdByNorm.set(newGroups[i2].norm, p2.id);
|
|
45755
|
-
});
|
|
45756
|
-
}
|
|
45757
|
-
const created = newGroups.length;
|
|
45758
|
-
const links = [];
|
|
45759
|
-
if (!opts.dryRun) {
|
|
45760
|
-
for (const [norm, g2] of groups) {
|
|
45761
|
-
const personId = personIdByNorm.get(norm);
|
|
45762
|
-
if (!personId)
|
|
45763
|
-
continue;
|
|
45764
|
-
for (const txnId of g2.txnIds)
|
|
45765
|
-
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)
|
|
45766
48146
|
}
|
|
45767
|
-
}
|
|
45768
|
-
|
|
45769
|
-
|
|
45770
|
-
|
|
45771
|
-
|
|
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)")
|
|
45772
48155
|
}
|
|
45773
|
-
}
|
|
45774
|
-
|
|
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
|
+
});
|
|
45775
48160
|
}
|
|
45776
48161
|
|
|
48162
|
+
// src/lib/statement-import.ts
|
|
48163
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
48164
|
+
import { basename } from "node:path";
|
|
48165
|
+
|
|
45777
48166
|
// src/tools/transactions.ts
|
|
48167
|
+
import { readFile } from "node:fs/promises";
|
|
45778
48168
|
function dedupeKey(date4, description, amount, type) {
|
|
45779
48169
|
return `${date4}|${description}|${amount.toFixed(2)}|${type}`;
|
|
45780
48170
|
}
|
|
@@ -45788,31 +48178,23 @@ function flagBatchDuplicates(txns) {
|
|
|
45788
48178
|
return false;
|
|
45789
48179
|
});
|
|
45790
48180
|
}
|
|
48181
|
+
var fileRowsSchema = exports_external.array(exports_external.object({
|
|
48182
|
+
date: exports_external.string().regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD"),
|
|
48183
|
+
amount: exports_external.number().positive(),
|
|
48184
|
+
type: exports_external.enum(["debit", "credit"]),
|
|
48185
|
+
description: exports_external.string(),
|
|
48186
|
+
referenceNumber: exports_external.string().optional()
|
|
48187
|
+
}));
|
|
45791
48188
|
async function importTransactions(client, crypto3, opts) {
|
|
45792
48189
|
let txns = opts.txns;
|
|
45793
48190
|
let duplicatesSkipped = 0;
|
|
45794
48191
|
if (txns.length > 0) {
|
|
45795
48192
|
const dates = txns.map((t2) => t2.date).sort();
|
|
45796
|
-
const existing =
|
|
45797
|
-
|
|
45798
|
-
|
|
45799
|
-
|
|
45800
|
-
|
|
45801
|
-
startDate: dates[0],
|
|
45802
|
-
endDate: dates[dates.length - 1]
|
|
45803
|
-
};
|
|
45804
|
-
if (cursor)
|
|
45805
|
-
params.cursor = cursor;
|
|
45806
|
-
const res = await client.get("/api/transactions", params);
|
|
45807
|
-
const batch = res.data ?? [];
|
|
45808
|
-
if (crypto3)
|
|
45809
|
-
await decryptTransactionFields(batch, crypto3.key);
|
|
45810
|
-
existing.push(...batch);
|
|
45811
|
-
if (!res.hasMore || !res.nextCursor || batch.length === 0)
|
|
45812
|
-
break;
|
|
45813
|
-
cursor = res.nextCursor;
|
|
45814
|
-
}
|
|
45815
|
-
const seen = new Set(existing.map((r2) => dedupeKey(String(r2.date), String(r2.description ?? ""), Number(r2.amount), String(r2.type))));
|
|
48193
|
+
const existing = await fetchAllRows(client, crypto3, {
|
|
48194
|
+
startDate: dates[0],
|
|
48195
|
+
endDate: dates[dates.length - 1]
|
|
48196
|
+
});
|
|
48197
|
+
const seen = new Set(existing.map((r2) => dedupeKey(r2.date, r2.description, r2.amount, r2.type)));
|
|
45816
48198
|
const before = txns.length;
|
|
45817
48199
|
txns = txns.filter((t2) => !seen.has(dedupeKey(t2.date, t2.description ?? "", t2.amount, t2.type)));
|
|
45818
48200
|
duplicatesSkipped = before - txns.length;
|
|
@@ -45840,16 +48222,9 @@ async function importTransactions(client, crypto3, opts) {
|
|
|
45840
48222
|
return { imported, duplicatesSkipped, duplicatesFlagged };
|
|
45841
48223
|
}
|
|
45842
48224
|
async function runRescan(client, crypto3) {
|
|
45843
|
-
|
|
45844
|
-
merchantAliases: [],
|
|
45845
|
-
personAliases: [],
|
|
45846
|
-
merchantRules: {},
|
|
45847
|
-
personRules: {}
|
|
45848
|
-
};
|
|
45849
|
-
let bag = EMPTY_BAG2;
|
|
48225
|
+
let bag = structuredClone(EMPTY_BAG);
|
|
45850
48226
|
try {
|
|
45851
|
-
|
|
45852
|
-
bag = learningRes.data?.bag ?? EMPTY_BAG2;
|
|
48227
|
+
bag = (await loadBag(client)).bag;
|
|
45853
48228
|
} catch {}
|
|
45854
48229
|
const merchantsRes = await client.get("/api/merchants");
|
|
45855
48230
|
const allMerchants = merchantsRes.data ?? [];
|
|
@@ -45859,23 +48234,8 @@ async function runRescan(client, crypto3) {
|
|
|
45859
48234
|
const localMerchants = allMerchants.filter((m2) => m2.householdId && m2.cleanName).map((m2) => ({ id: m2.id, name: m2.cleanName }));
|
|
45860
48235
|
const merchantCategoryById = new Map(allMerchants.filter((m2) => m2.categoryId).map((m2) => [m2.id, m2.categoryId]));
|
|
45861
48236
|
const persons = await loadDecryptedPersons(client, crypto3);
|
|
45862
|
-
const allRows =
|
|
45863
|
-
const
|
|
45864
|
-
let cursor;
|
|
45865
|
-
const MAX_PAGES = 100;
|
|
45866
|
-
for (let page = 0;page < MAX_PAGES; page++) {
|
|
45867
|
-
const params = { limit };
|
|
45868
|
-
if (cursor)
|
|
45869
|
-
params.cursor = cursor;
|
|
45870
|
-
const res = await client.get("/api/transactions", params);
|
|
45871
|
-
const rows = res.data ?? [];
|
|
45872
|
-
if (crypto3)
|
|
45873
|
-
await decryptTransactionFields(rows, crypto3.key);
|
|
45874
|
-
allRows.push(...rows);
|
|
45875
|
-
if (!res.hasMore || !res.nextCursor || rows.length === 0)
|
|
45876
|
-
break;
|
|
45877
|
-
cursor = res.nextCursor;
|
|
45878
|
-
}
|
|
48237
|
+
const allRows = await fetchAllRows(client, crypto3, { unresolved: "true" });
|
|
48238
|
+
const categoryByTxn = new Map(allRows.map((r2) => [r2.id, r2.categoryId]));
|
|
45879
48239
|
const plan = autoCategorizeFromDescriptions({
|
|
45880
48240
|
rows: allRows,
|
|
45881
48241
|
bag,
|
|
@@ -45886,25 +48246,23 @@ async function runRescan(client, crypto3) {
|
|
|
45886
48246
|
let categorized = 0;
|
|
45887
48247
|
const links = plan.suggestions.flatMap((s2) => {
|
|
45888
48248
|
const categoryId = s2.categoryId ?? (s2.entity.type === "merchant" ? merchantCategoryById.get(s2.entity.merchantId) ?? null : null);
|
|
45889
|
-
|
|
45890
|
-
|
|
45891
|
-
|
|
45892
|
-
|
|
45893
|
-
|
|
45894
|
-
|
|
45895
|
-
|
|
48249
|
+
return s2.transactionIds.map((txId) => {
|
|
48250
|
+
const applyCategory = categoryId && !categoryByTxn.get(txId);
|
|
48251
|
+
if (applyCategory)
|
|
48252
|
+
categorized += 1;
|
|
48253
|
+
return {
|
|
48254
|
+
transactionId: txId,
|
|
48255
|
+
...s2.entity.type === "merchant" ? { merchantId: s2.entity.merchantId } : { personId: s2.entity.personId },
|
|
48256
|
+
...applyCategory ? { categoryId } : {}
|
|
48257
|
+
};
|
|
48258
|
+
});
|
|
45896
48259
|
});
|
|
45897
|
-
|
|
45898
|
-
const CHUNK = 500;
|
|
45899
|
-
for (let i2 = 0;i2 < links.length; i2 += CHUNK) {
|
|
45900
|
-
await client.post("/api/transactions/bulk-link-entity", { links: links.slice(i2, i2 + CHUNK) });
|
|
45901
|
-
}
|
|
45902
|
-
}
|
|
48260
|
+
await postLinks(client, links);
|
|
45903
48261
|
return { linked: links.length, categorized, unresolved: plan.unresolvedCount };
|
|
45904
48262
|
}
|
|
45905
48263
|
function registerTransactionTools(server, client, crypto3) {
|
|
45906
48264
|
server.registerTool("get_transactions", {
|
|
45907
|
-
description: "Search
|
|
48265
|
+
description: "Search/filter transactions. Returns COMPACT rows {id,date,amt,t:'dr'|'cr',desc,cat,merchant,person,flags} " + "(default 25, max 100) plus `more`/`cursor` for the next page and, on page 1, `spent`/`income` totals over the whole filter. " + "To find everything paid to a counterparty pass `personName` or `personId` (indexed). " + "Free-text `search` only matches plaintext merchant names, not encrypted descriptions. " + "Pass `full: true` for the raw joined rows (much larger — avoid unless needed).",
|
|
45908
48266
|
inputSchema: {
|
|
45909
48267
|
search: exports_external.string().optional().describe("Search merchant name (NOT encrypted descriptions)"),
|
|
45910
48268
|
personId: exports_external.string().optional().describe("Filter to one person (exact, indexed)"),
|
|
@@ -45914,69 +48272,126 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
45914
48272
|
owner: exports_external.string().optional(),
|
|
45915
48273
|
categorySlug: exports_external.string().optional(),
|
|
45916
48274
|
categoryType: exports_external.enum(["needs", "wants", "investments", "income", "transfer"]).optional(),
|
|
45917
|
-
source: exports_external.string().optional(),
|
|
45918
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"),
|
|
45919
48292
|
amountMin: exports_external.number().optional(),
|
|
45920
48293
|
amountMax: exports_external.number().optional(),
|
|
45921
48294
|
uncategorized: exports_external.boolean().optional().describe("Only transactions with no category (server-side filter)"),
|
|
45922
|
-
limit: exports_external.number().optional().describe("Max results (default
|
|
45923
|
-
cursor: exports_external.string().optional().describe("Opaque cursor from a prior page's
|
|
48295
|
+
limit: exports_external.number().optional().describe("Max results (default 25, max 100)"),
|
|
48296
|
+
cursor: exports_external.string().optional().describe("Opaque cursor from a prior page's `cursor`"),
|
|
48297
|
+
full: exports_external.boolean().optional().describe("Return raw joined rows instead of compact rows")
|
|
45924
48298
|
}
|
|
45925
48299
|
}, async (params) => {
|
|
45926
|
-
const { personName, categorySlug, ...rest } = params;
|
|
48300
|
+
const { personName, categorySlug, full, limit, ...rest } = params;
|
|
45927
48301
|
const apiParams = { ...rest };
|
|
45928
48302
|
if (apiParams.uncategorized !== true)
|
|
45929
48303
|
delete apiParams.uncategorized;
|
|
45930
48304
|
if (categorySlug) {
|
|
45931
|
-
|
|
45932
|
-
const all2 = cats.data.flatMap((c2) => [c2, ...c2.children ?? []]);
|
|
45933
|
-
const match = all2.find((c2) => c2.slug === categorySlug);
|
|
45934
|
-
if (match)
|
|
45935
|
-
apiParams.categoryId = match.id;
|
|
48305
|
+
apiParams.categoryId = requireCategory(await loadCategoryIndex(client), categorySlug).id;
|
|
45936
48306
|
}
|
|
45937
48307
|
if (personName && !apiParams.personId) {
|
|
45938
48308
|
const persons = await loadDecryptedPersons(client, crypto3);
|
|
45939
48309
|
const match = resolvePersonByName(persons, personName);
|
|
48310
|
+
const err = (error2) => ({
|
|
48311
|
+
content: [{ type: "text", text: JSON.stringify({ error: error2 }) }]
|
|
48312
|
+
});
|
|
45940
48313
|
if (!match) {
|
|
45941
|
-
return {
|
|
45942
|
-
content: [
|
|
45943
|
-
{
|
|
45944
|
-
type: "text",
|
|
45945
|
-
text: JSON.stringify({
|
|
45946
|
-
error: `No person matches "${personName}". Use list_persons to see known counterparties, or run backfill_persons if persons haven't been extracted yet.`
|
|
45947
|
-
})
|
|
45948
|
-
}
|
|
45949
|
-
]
|
|
45950
|
-
};
|
|
48314
|
+
return err(`No person matches "${personName}". Use list_persons to see known counterparties, or run backfill_persons if persons haven't been extracted yet.`);
|
|
45951
48315
|
}
|
|
45952
48316
|
if ("candidates" in match) {
|
|
45953
|
-
return {
|
|
45954
|
-
content: [
|
|
45955
|
-
{
|
|
45956
|
-
type: "text",
|
|
45957
|
-
text: JSON.stringify({
|
|
45958
|
-
error: `Ambiguous person "${personName}". Candidates: ${match.candidates.join(", ")}. Re-run with an exact name or personId.`
|
|
45959
|
-
})
|
|
45960
|
-
}
|
|
45961
|
-
]
|
|
45962
|
-
};
|
|
48317
|
+
return err(`Ambiguous person "${personName}". Candidates: ${match.candidates.join(", ")}. Re-run with an exact name or personId.`);
|
|
45963
48318
|
}
|
|
45964
48319
|
apiParams.personId = match.personId;
|
|
45965
48320
|
}
|
|
48321
|
+
apiParams.limit = Math.min(Math.max(limit ?? 25, 1), full ? 200 : 100);
|
|
48322
|
+
if (!full)
|
|
48323
|
+
apiParams.view = "lean";
|
|
45966
48324
|
const data = await client.get("/api/transactions", Object.fromEntries(Object.entries(apiParams).filter(([, v2]) => v2 !== undefined).map(([k2, v2]) => [k2, String(v2)])));
|
|
45967
|
-
|
|
45968
|
-
|
|
45969
|
-
|
|
45970
|
-
|
|
45971
|
-
|
|
45972
|
-
|
|
45973
|
-
|
|
45974
|
-
|
|
45975
|
-
|
|
45976
|
-
|
|
45977
|
-
|
|
45978
|
-
|
|
45979
|
-
|
|
48325
|
+
const rows = data.data ?? [];
|
|
48326
|
+
if (crypto3)
|
|
48327
|
+
await decryptTransactionFields(rows, crypto3.key);
|
|
48328
|
+
const personNames = new Map;
|
|
48329
|
+
if (rows.some((r2) => r2.personId)) {
|
|
48330
|
+
for (const p2 of await loadDecryptedPersons(client, crypto3))
|
|
48331
|
+
personNames.set(p2.id, p2.name);
|
|
48332
|
+
}
|
|
48333
|
+
if (full) {
|
|
48334
|
+
for (const r2 of rows) {
|
|
48335
|
+
const person = r2.person;
|
|
48336
|
+
if (person && personNames.has(person.id))
|
|
48337
|
+
person.name = personNames.get(person.id) ?? "";
|
|
48338
|
+
}
|
|
48339
|
+
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
48340
|
+
}
|
|
48341
|
+
const out = {
|
|
48342
|
+
rows: rows.map((r2) => toLeanRow(r2, personNames)),
|
|
48343
|
+
...data.hasMore ? { more: true, cursor: data.nextCursor } : {},
|
|
48344
|
+
...data.summary ? { spent: data.summary.spent, income: data.summary.income } : {}
|
|
48345
|
+
};
|
|
48346
|
+
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
48347
|
+
});
|
|
48348
|
+
server.registerTool("get_uncategorized_groups", {
|
|
48349
|
+
description: "Token-cheap way to categorize: groups still-uncategorized transactions by merchant-like pattern " + "(descriptions are decrypted locally in private mode) and returns only " + "{pattern,t:'dr'|'cr',n,total,sample} per group, biggest ₹ first — never individual rows. " + "Then call `categorize_groups` with a category per pattern. Skip tiny groups via `minTotal`.",
|
|
48350
|
+
inputSchema: {
|
|
48351
|
+
startDate: exports_external.string().optional().describe("YYYY-MM-DD"),
|
|
48352
|
+
endDate: exports_external.string().optional().describe("YYYY-MM-DD"),
|
|
48353
|
+
minTotal: exports_external.number().optional().describe("Hide groups whose total ₹ is below this"),
|
|
48354
|
+
limit: exports_external.number().optional().describe("Max groups (default 40, max 100)")
|
|
48355
|
+
}
|
|
48356
|
+
}, async ({ startDate, endDate, minTotal, limit }) => {
|
|
48357
|
+
const rows = await fetchUncategorized(client, crypto3, { startDate, endDate });
|
|
48358
|
+
const all2 = groupRows(rows);
|
|
48359
|
+
const shown = all2.filter((g3) => g3.total >= (minTotal ?? 0)).slice(0, Math.min(Math.max(limit ?? 40, 1), 100));
|
|
48360
|
+
const shownRows = shown.reduce((n2, g3) => n2 + g3.rows.length, 0);
|
|
48361
|
+
const out = {
|
|
48362
|
+
groups: shown.map((g3) => ({
|
|
48363
|
+
pattern: g3.pattern,
|
|
48364
|
+
t: g3.type === "credit" ? "cr" : "dr",
|
|
48365
|
+
n: g3.rows.length,
|
|
48366
|
+
total: Math.round(g3.total),
|
|
48367
|
+
sample: g3.rows[0].description.slice(0, 50)
|
|
48368
|
+
})),
|
|
48369
|
+
rows: rows.length,
|
|
48370
|
+
groupsTotal: all2.length,
|
|
48371
|
+
rowsCovered: shownRows
|
|
48372
|
+
};
|
|
48373
|
+
return { content: [{ type: "text", text: JSON.stringify(out) }] };
|
|
48374
|
+
});
|
|
48375
|
+
server.registerTool("categorize_groups", {
|
|
48376
|
+
description: "Assign a category to whole groups from `get_uncategorized_groups` in ONE call. " + "For each pattern it categorizes every matching uncategorized transaction and saves a learning rule " + "(merchant + alias + category) so future imports auto-categorize it. " + "Unknown categorySlug fails before any write. Use dryRun to preview.",
|
|
48377
|
+
inputSchema: {
|
|
48378
|
+
assignments: exports_external.array(exports_external.object({
|
|
48379
|
+
pattern: exports_external.string().describe("Group pattern exactly as returned"),
|
|
48380
|
+
type: exports_external.enum(["debit", "credit"]).optional().describe("Limit to debits or credits"),
|
|
48381
|
+
categorySlug: exports_external.string().describe("Category slug (see list_categories)"),
|
|
48382
|
+
learn: exports_external.boolean().optional().describe("false = don't save a reusable rule (use for short/generic patterns)")
|
|
48383
|
+
})).min(1).max(100),
|
|
48384
|
+
startDate: exports_external.string().optional().describe("Same range used when listing groups"),
|
|
48385
|
+
endDate: exports_external.string().optional(),
|
|
48386
|
+
dryRun: exports_external.boolean().optional()
|
|
48387
|
+
}
|
|
48388
|
+
}, async ({ assignments, startDate, endDate, dryRun }) => {
|
|
48389
|
+
const result = await applyGroupAssignments(client, crypto3, assignments, {
|
|
48390
|
+
dryRun,
|
|
48391
|
+
startDate,
|
|
48392
|
+
endDate
|
|
48393
|
+
});
|
|
48394
|
+
return { content: [{ type: "text", text: JSON.stringify(result) }] };
|
|
45980
48395
|
});
|
|
45981
48396
|
server.registerTool("backfill_persons", {
|
|
45982
48397
|
description: "Extract UPI/IMPS counterparties from unlinked transactions, create person records for them (deduped by name), and link transactions via personId — so you can later filter all payments to a person. Idempotent: re-running reuses existing persons and skips already-linked rows. Pass dryRun to preview without writing.",
|
|
@@ -46005,13 +48420,9 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46005
48420
|
if (crypto3 && body.notes)
|
|
46006
48421
|
body.notes = await encryptField(body.notes, crypto3.key);
|
|
46007
48422
|
if (body.categorySlug) {
|
|
46008
|
-
const
|
|
46009
|
-
|
|
46010
|
-
|
|
46011
|
-
if (cat) {
|
|
46012
|
-
body.categoryId = cat.id;
|
|
46013
|
-
body.categoryType = cat.type;
|
|
46014
|
-
}
|
|
48423
|
+
const cat = requireCategory(await loadCategoryIndex(client), body.categorySlug);
|
|
48424
|
+
body.categoryId = cat.id;
|
|
48425
|
+
body.categoryType = cat.type;
|
|
46015
48426
|
delete body.categorySlug;
|
|
46016
48427
|
}
|
|
46017
48428
|
const data = await client.patch(`/api/transactions/${id}`, body);
|
|
@@ -46041,6 +48452,12 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46041
48452
|
resolution: exports_external.enum(["keep", "ignore"]).optional().describe("[resolve] 'ignore' excludes the duplicates, 'keep' unflags them")
|
|
46042
48453
|
}
|
|
46043
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
|
+
}
|
|
46044
48461
|
if (action === "flag") {
|
|
46045
48462
|
const data2 = await client.patch(`/api/transactions/${transactionId}`, {
|
|
46046
48463
|
isDuplicate: true,
|
|
@@ -46050,8 +48467,8 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46050
48467
|
}
|
|
46051
48468
|
if (action === "list") {
|
|
46052
48469
|
const data2 = await client.get("/api/transactions/duplicates");
|
|
46053
|
-
if (crypto3 && data2.
|
|
46054
|
-
await decryptTransactionFields(data2.
|
|
48470
|
+
if (crypto3 && data2.data)
|
|
48471
|
+
await decryptTransactionFields(data2.data, crypto3.key);
|
|
46055
48472
|
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
46056
48473
|
}
|
|
46057
48474
|
const data = await client.post("/api/transactions/duplicates/resolve", {
|
|
@@ -46080,7 +48497,7 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46080
48497
|
source: exports_external.enum(["bank_statement", "cc_statement", "manual", "telegram", "wisprflow", "ai_import"]).describe("Origin of the statement"),
|
|
46081
48498
|
granularity: exports_external.number().int().min(1).max(5).describe("Source trust level: 1=bank statement, 2=CC statement, 3=manual, 4=app export, 5=telegram/AI"),
|
|
46082
48499
|
transactions: exports_external.array(exports_external.object({
|
|
46083
|
-
date: exports_external.string().
|
|
48500
|
+
date: exports_external.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
|
|
46084
48501
|
amount: exports_external.number().positive(),
|
|
46085
48502
|
type: exports_external.enum(["debit", "credit"]),
|
|
46086
48503
|
description: exports_external.string(),
|
|
@@ -46119,7 +48536,12 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46119
48536
|
const parsed = JSON.parse(raw);
|
|
46120
48537
|
if (!Array.isArray(parsed))
|
|
46121
48538
|
throw new Error("File must contain a JSON array");
|
|
46122
|
-
|
|
48539
|
+
const result = fileRowsSchema.safeParse(parsed);
|
|
48540
|
+
if (!result.success) {
|
|
48541
|
+
const i2 = result.error.issues[0];
|
|
48542
|
+
throw new Error(`row ${String(i2.path[0])}: ${i2.path.slice(1).join(".")} ${i2.message}`);
|
|
48543
|
+
}
|
|
48544
|
+
txns = result.data;
|
|
46123
48545
|
} catch (e2) {
|
|
46124
48546
|
throw new Error(`Failed to read/parse ${filePath}: ${e2 instanceof Error ? e2.message : String(e2)}`);
|
|
46125
48547
|
}
|
|
@@ -46148,9 +48570,13 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46148
48570
|
})).min(1).max(500)
|
|
46149
48571
|
}
|
|
46150
48572
|
}, async ({ links }) => {
|
|
46151
|
-
const
|
|
46152
|
-
const
|
|
46153
|
-
const
|
|
48573
|
+
const catIndex = await loadCategoryIndex(client);
|
|
48574
|
+
const typeById = new Map([...catIndex.byId].map(([id, c2]) => [id, c2.type]));
|
|
48575
|
+
const unknown3 = [
|
|
48576
|
+
...new Set(links.map((l2) => l2.categoryId))
|
|
48577
|
+
].filter((id) => !typeById.has(id));
|
|
48578
|
+
if (unknown3.length)
|
|
48579
|
+
throw new Error(`Unknown categoryId(s): ${unknown3.join(", ")}`);
|
|
46154
48580
|
const groups = new Map;
|
|
46155
48581
|
for (const { transactionId, categoryId } of links) {
|
|
46156
48582
|
const arr = groups.get(categoryId) ?? [];
|
|
@@ -46201,11 +48627,18 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46201
48627
|
}).optional().describe("Filter params — deletes all matching transactions (provide one of id / ids / filters)")
|
|
46202
48628
|
}
|
|
46203
48629
|
}, async ({ id, ids, filters }) => {
|
|
48630
|
+
const given = [id, ids, filters].filter((v2) => v2 !== undefined).length;
|
|
48631
|
+
if (given !== 1) {
|
|
48632
|
+
throw new Error("Provide exactly one of id, ids, or filters");
|
|
48633
|
+
}
|
|
48634
|
+
if (filters && Object.values(filters).every((v2) => v2 === undefined)) {
|
|
48635
|
+
throw new Error("filters is empty — refusing to delete every transaction");
|
|
48636
|
+
}
|
|
46204
48637
|
if (id) {
|
|
46205
48638
|
const data2 = await client.delete(`/api/transactions/${id}`);
|
|
46206
48639
|
return { content: [{ type: "text", text: JSON.stringify(data2) }] };
|
|
46207
48640
|
}
|
|
46208
|
-
const body = ids ? { ids } : { filters
|
|
48641
|
+
const body = ids ? { ids } : { filters };
|
|
46209
48642
|
const data = await client.delete("/api/transactions/bulk", body);
|
|
46210
48643
|
return { content: [{ type: "text", text: JSON.stringify(data) }] };
|
|
46211
48644
|
});
|
|
@@ -46229,16 +48662,152 @@ function registerTransactionTools(server, client, crypto3) {
|
|
|
46229
48662
|
});
|
|
46230
48663
|
}
|
|
46231
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
|
+
|
|
46232
48801
|
// src/tools/upcoming.ts
|
|
46233
48802
|
function registerUpcomingTools(server, client) {
|
|
46234
48803
|
server.registerTool("get_upcoming", {
|
|
46235
48804
|
description: "Get upcoming payments (EMIs, SIPs, insurance renewals, CC billing) due in the next N days.",
|
|
46236
48805
|
inputSchema: {
|
|
46237
|
-
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)")
|
|
46238
48807
|
}
|
|
46239
|
-
}, async (
|
|
46240
|
-
const
|
|
46241
|
-
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) }] };
|
|
46242
48811
|
});
|
|
46243
48812
|
}
|
|
46244
48813
|
|
|
@@ -46253,6 +48822,79 @@ function registerWaitlistTools(server, client) {
|
|
|
46253
48822
|
});
|
|
46254
48823
|
}
|
|
46255
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
|
+
|
|
46256
48898
|
// src/index.ts
|
|
46257
48899
|
async function main() {
|
|
46258
48900
|
const apiUrl = process.env.PAISA_API_URL;
|
|
@@ -46263,55 +48905,16 @@ async function main() {
|
|
|
46263
48905
|
process.exit(1);
|
|
46264
48906
|
}
|
|
46265
48907
|
const client = new PaisaApiClient(apiUrl, apiToken);
|
|
46266
|
-
|
|
46267
|
-
|
|
46268
|
-
const userId = process.env.PAISA_USER_ID;
|
|
46269
|
-
if (pin) {
|
|
46270
|
-
if (!userId) {
|
|
46271
|
-
console.error("Error: PAISA_USER_ID is required when PAISA_PIN is set.");
|
|
46272
|
-
process.exit(1);
|
|
46273
|
-
}
|
|
46274
|
-
const cryptoMeta = await client.get("/api/auth-crypto").catch(() => null);
|
|
46275
|
-
if (cryptoMeta && (cryptoMeta.kdfVersion ?? 1) >= 2 && cryptoMeta.kdfSalt && cryptoMeta.wrappedDek) {
|
|
46276
|
-
const kek = await deriveKEK(pin, cryptoMeta.kdfSalt);
|
|
46277
|
-
const dek = await unwrapDEK(cryptoMeta.wrappedDek, kek);
|
|
46278
|
-
cryptoCtx = { key: dek };
|
|
46279
|
-
} else {
|
|
46280
|
-
const key = await deriveKey(userId, pin, userId);
|
|
46281
|
-
cryptoCtx = { key };
|
|
46282
|
-
}
|
|
48908
|
+
const cryptoCtx = await initCrypto(client, process.env.PAISA_PIN, process.env.PAISA_USER_ID);
|
|
48909
|
+
if (cryptoCtx)
|
|
46283
48910
|
console.error("Paisa MCP: private mode — encryption active");
|
|
46284
|
-
}
|
|
46285
48911
|
const crypto3 = cryptoCtx;
|
|
46286
48912
|
const server = new McpServer({
|
|
46287
48913
|
name: "paisa",
|
|
46288
|
-
version:
|
|
46289
|
-
});
|
|
46290
|
-
|
|
46291
|
-
|
|
46292
|
-
registerPersonTools(server, client, crypto3);
|
|
46293
|
-
registerCategoryTools(server, client);
|
|
46294
|
-
registerAnalyticsTools(server, client, crypto3);
|
|
46295
|
-
registerAccountTools(server, client);
|
|
46296
|
-
registerGoalTools(server, client);
|
|
46297
|
-
registerBudgetTools(server, client);
|
|
46298
|
-
registerUpcomingTools(server, client);
|
|
46299
|
-
registerDashboardTools(server, client, crypto3);
|
|
46300
|
-
registerRecurringTools(server, client);
|
|
46301
|
-
registerAlertTools(server, client);
|
|
46302
|
-
registerAlertRuleTools(server, client);
|
|
46303
|
-
registerDebtTools(server, client);
|
|
46304
|
-
registerReportTools(server, client);
|
|
46305
|
-
registerExportTools(server, client, crypto3);
|
|
46306
|
-
registerHeatmapTools(server, client);
|
|
46307
|
-
registerHouseholdTools(server, client);
|
|
46308
|
-
registerHouseholdLearningTools(server, client, crypto3);
|
|
46309
|
-
registerNetWorthTools(server, client);
|
|
46310
|
-
registerInvestmentTools(server, client);
|
|
46311
|
-
registerProfileTools(server, client);
|
|
46312
|
-
registerAuthCryptoTools(server, client);
|
|
46313
|
-
registerWaitlistTools(server, client);
|
|
46314
|
-
registerPushTools(server, client);
|
|
48914
|
+
version: VERSION3
|
|
48915
|
+
});
|
|
48916
|
+
for (const { register } of REGISTRATIONS)
|
|
48917
|
+
register(server, client, crypto3);
|
|
46315
48918
|
const transport = new StdioServerTransport;
|
|
46316
48919
|
await server.connect(transport);
|
|
46317
48920
|
console.error("Paisa MCP server running on stdio");
|
|
@@ -46321,5 +48924,5 @@ main().catch((err) => {
|
|
|
46321
48924
|
process.exit(1);
|
|
46322
48925
|
});
|
|
46323
48926
|
|
|
46324
|
-
//# debugId=
|
|
48927
|
+
//# debugId=BC4AFE8852AA4C8E64756E2164756E21
|
|
46325
48928
|
//# sourceMappingURL=index.js.map
|