paisa-mcp 0.0.21 → 0.0.22

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