paisa-mcp 0.0.20 → 0.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +3339 -736
  2. package/dist/index.js.map +43 -22
  3. package/package.json +11 -11
package/dist/index.js CHANGED
@@ -28092,7 +28092,7 @@ function finalize(ctx, schema) {
28092
28092
  result.$schema = "http://json-schema.org/draft-07/schema#";
28093
28093
  } else if (ctx.target === "draft-04") {
28094
28094
  result.$schema = "http://json-schema.org/draft-04/schema#";
28095
- } else if (ctx.target === "openapi-3.0") {} else {}
28095
+ } else if (ctx.target === "openapi-3.0") {}
28096
28096
  if (ctx.external?.uri) {
28097
28097
  const id = ctx.external.registry.get(schema)?.id;
28098
28098
  if (!id)
@@ -28340,7 +28340,7 @@ var literalProcessor = (schema, ctx, json, _params) => {
28340
28340
  if (val === undefined) {
28341
28341
  if (ctx.unrepresentable === "throw") {
28342
28342
  throw new Error("Literal `undefined` cannot be represented in JSON Schema");
28343
- } else {}
28343
+ }
28344
28344
  } else if (typeof val === "bigint") {
28345
28345
  if (ctx.unrepresentable === "throw") {
28346
28346
  throw new Error("BigInt literals cannot be represented in JSON Schema");
@@ -38157,6 +38157,21 @@ axios.default = axios;
38157
38157
  var axios_default = axios;
38158
38158
 
38159
38159
  // src/api-client.ts
38160
+ class ApiError extends Error {
38161
+ status;
38162
+ body;
38163
+ method;
38164
+ path;
38165
+ constructor(message, status, body, method, path) {
38166
+ super(message);
38167
+ this.status = status;
38168
+ this.body = body;
38169
+ this.method = method;
38170
+ this.path = path;
38171
+ this.name = "ApiError";
38172
+ }
38173
+ }
38174
+
38160
38175
  class PaisaApiClient {
38161
38176
  http;
38162
38177
  constructor(baseUrl, token) {
@@ -38169,12 +38184,12 @@ class PaisaApiClient {
38169
38184
  }
38170
38185
  });
38171
38186
  this.http.interceptors.response.use((res) => res, (err) => {
38172
- const status = err.response?.status ?? "?";
38187
+ const status = err.response?.status;
38173
38188
  const body = err.response?.data;
38174
38189
  const detail = typeof body === "string" ? body : JSON.stringify(body ?? "");
38175
38190
  const method = err.config?.method?.toUpperCase() ?? "?";
38176
38191
  const url2 = err.config?.url ?? "?";
38177
- throw new 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
@@ -42773,7 +42846,7 @@ function registerHeatmapTools(server, client) {
42773
42846
  });
42774
42847
  }
42775
42848
 
42776
- // src/tools/household-learning.ts
42849
+ // src/lib/learning-bag.ts
42777
42850
  var EMPTY_BAG = {
42778
42851
  merchantAliases: [],
42779
42852
  personAliases: [],
@@ -42784,15 +42857,17 @@ async function loadBag(client) {
42784
42857
  const res = await client.get("/api/household-learning");
42785
42858
  if (!res.data)
42786
42859
  return { bag: structuredClone(EMPTY_BAG), version: 0 };
42860
+ if (res.data.readable === false) {
42861
+ throw new Error("Household learning bag is unreadable (decrypt/parse failed); refusing to modify it. Check ENCRYPTION_KEY / the stored blob before retrying.");
42862
+ }
42787
42863
  return { bag: res.data.bag, version: res.data.version };
42788
42864
  }
42789
42865
  async function saveBag(client, bag, expectedVersion) {
42790
- const res = await client.put("/api/household-learning", {
42791
- bag,
42792
- expectedVersion
42793
- });
42866
+ const res = await client.put("/api/household-learning", { bag, expectedVersion });
42794
42867
  return { version: res.data.version };
42795
42868
  }
42869
+
42870
+ // src/tools/household-learning.ts
42796
42871
  function registerHouseholdLearningTools(server, client, _crypto) {
42797
42872
  server.registerTool("list_learning", {
42798
42873
  description: "List a slice of what the user has taught the categorizer for this household: " + "merchant->category rules, person->category rules, merchant aliases, or person aliases.",
@@ -42887,12 +42962,24 @@ function registerHouseholdTools(server, client) {
42887
42962
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
42888
42963
  });
42889
42964
  server.registerTool("create_invite", {
42890
- description: "Create an invite link to add a new member to the household.",
42891
- inputSchema: {
42892
- expiresInHours: exports_external.number().optional().describe("Invite expiry in hours (default: 72)")
42893
- }
42894
- }, async (body) => {
42895
- 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}`);
42896
42983
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
42897
42984
  });
42898
42985
  }
@@ -42900,20 +42987,26 @@ function registerHouseholdTools(server, client) {
42900
42987
  // src/tools/investments.ts
42901
42988
  function registerInvestmentTools(server, client) {
42902
42989
  server.registerTool("list_investments", {
42903
- description: "List investments by type. holdings = mutual funds, stocks, etc. with current values; sips = Systematic Investment Plans; fixed_deposits = fixed deposits.",
42990
+ description: "List investments by type. holdings = mutual funds, stocks, etc. with current values; sips = Systematic Investment Plans; fixed_deposits = fixed deposits; transactions = buy/sell/dividend history of one holding (pass id).",
42904
42991
  inputSchema: {
42905
- type: exports_external.enum(["holdings", "sips", "fixed_deposits"]).describe("Which investments to list")
42992
+ type: exports_external.enum(["holdings", "sips", "fixed_deposits", "transactions"]).describe("Which investments to list"),
42993
+ id: exports_external.string().optional().describe("Investment UUID — required for type='transactions'")
42906
42994
  }
42907
- }, async ({ type }) => {
42908
- const endpoint = type === "sips" ? "/api/investments/sips" : type === "fixed_deposits" ? "/api/investments/fds" : "/api/investments";
42909
- 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");
42910
43003
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
42911
43004
  });
42912
43005
  server.registerTool("upsert_investment", {
42913
- description: "Create or update an investment. " + "type=sip: omit id to add a new SIP (name, amount, startDate, sipDay, owner required); pass id to update an existing SIP, including pausing/stopping it via status. " + "type=fixed_deposit: create-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).",
42914
43007
  inputSchema: {
42915
43008
  type: exports_external.enum(["sip", "fixed_deposit", "holding"]).describe("Investment type"),
42916
- 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"),
42917
43010
  name: exports_external.string().optional().describe('sip: fund name (e.g. "Parag Parikh Flexi Cap")'),
42918
43011
  startDate: exports_external.string().optional().describe("sip & fixed_deposit: YYYY-MM-DD"),
42919
43012
  sipDay: exports_external.number().optional().describe("sip: day of month the SIP debits"),
@@ -42926,7 +43019,7 @@ function registerInvestmentTools(server, client) {
42926
43019
  maturityDate: exports_external.string().optional().describe("fixed_deposit: YYYY-MM-DD"),
42927
43020
  notes: exports_external.string().optional().describe("fixed_deposit: notes"),
42928
43021
  holdingType: exports_external.enum(["mutual_fund", "stock", "gold", "ppf", "nps", "other"]).optional().describe("holding: asset type"),
42929
- platform: exports_external.string().optional().describe("holding: broker/platform (e.g. groww); defaults to 'manual'"),
43022
+ platform: exports_external.string().optional().describe("sip & holding: broker/platform (e.g. groww); defaults to 'manual'"),
42930
43023
  symbol: exports_external.string().optional().describe("holding: ticker symbol (optional)"),
42931
43024
  isin: exports_external.string().optional().describe("holding: ISIN (optional)"),
42932
43025
  units: exports_external.string().optional().describe("holding: units held (optional)"),
@@ -42939,15 +43032,15 @@ function registerInvestmentTools(server, client) {
42939
43032
  if (type === "holding") {
42940
43033
  const { name, holdingType, owner: owner2, platform, symbol, isin, units, investedAmount } = fields;
42941
43034
  const { currentValue } = fields;
42942
- const missing2 = [
43035
+ const missing = [
42943
43036
  ["name", name],
42944
43037
  ["holdingType", holdingType],
42945
43038
  ["owner", owner2],
42946
43039
  ["investedAmount", investedAmount],
42947
43040
  ["currentValue", currentValue]
42948
43041
  ].filter(([, v]) => v === undefined).map(([k]) => k);
42949
- if (missing2.length > 0) {
42950
- 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(", ")}`);
42951
43044
  }
42952
43045
  const data2 = await client.post("/api/investments/holdings", {
42953
43046
  name,
@@ -42964,7 +43057,7 @@ function registerInvestmentTools(server, client) {
42964
43057
  }
42965
43058
  if (type === "sip") {
42966
43059
  const {
42967
- id,
43060
+ id: id2,
42968
43061
  name,
42969
43062
  amount: amount2,
42970
43063
  startDate: startDate2,
@@ -42973,362 +43066,705 @@ function registerInvestmentTools(server, client) {
42973
43066
  folioNumber,
42974
43067
  category,
42975
43068
  status,
42976
- endDate
43069
+ endDate,
43070
+ platform
42977
43071
  } = fields;
42978
- if (!id) {
42979
- const missing2 = [
43072
+ if (!id2) {
43073
+ const missing = [
42980
43074
  ["name", name],
42981
43075
  ["amount", amount2],
42982
43076
  ["startDate", startDate2],
42983
43077
  ["sipDay", sipDay],
42984
43078
  ["owner", owner2]
42985
43079
  ].filter(([, v]) => v === undefined).map(([k]) => k);
42986
- if (missing2.length > 0) {
42987
- 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(", ")}`);
42988
43082
  }
42989
43083
  }
42990
43084
  const sipFields = {
42991
43085
  name,
42992
43086
  amount: amount2,
42993
43087
  startDate: startDate2,
42994
- sipDay,
43088
+ sipDate: sipDay,
42995
43089
  owner: owner2,
42996
43090
  folioNumber,
42997
43091
  category,
42998
43092
  status,
42999
- endDate
43093
+ endDate,
43094
+ platform: id2 ? platform : platform ?? "manual"
43000
43095
  };
43001
- const data2 = 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);
43002
43097
  return { content: [{ type: "text", text: JSON.stringify(data2) }] };
43003
43098
  }
43004
- const { bankName, amount, interestRate, startDate, maturityDate, owner, notes } = fields;
43005
- const missing = [
43006
- ["bankName", bankName],
43007
- ["amount", amount],
43008
- ["interestRate", interestRate],
43009
- ["startDate", startDate],
43010
- ["maturityDate", maturityDate],
43011
- ["owner", owner]
43012
- ].filter(([, v]) => v === undefined).map(([k]) => k);
43013
- if (missing.length > 0) {
43014
- throw new Error(`Missing required fields for fixed deposit: ${missing.join(", ")}`);
43015
- }
43016
- const data = await client.post("/api/investments/fds", {
43017
- bankName,
43018
- 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,
43019
43116
  interestRate,
43020
43117
  startDate,
43021
43118
  maturityDate,
43022
43119
  owner,
43023
43120
  notes
43024
- });
43025
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43026
- });
43027
- }
43028
-
43029
- // src/tools/merchants.ts
43030
- async function categorySlugToId(client) {
43031
- const res = await client.get("/api/categories");
43032
- const map2 = new Map;
43033
- for (const root of res.data ?? []) {
43034
- if (root.slug)
43035
- map2.set(root.slug, root.id);
43036
- for (const child of root.children ?? []) {
43037
- if (child.slug)
43038
- map2.set(child.slug, child.id);
43039
- }
43040
- }
43041
- return map2;
43042
- }
43043
- function registerMerchantTools(server, client, crypto3) {
43044
- server.registerTool("upsert_merchant", {
43045
- description: "Create or update a merchant in the dictionary. Omit `id` to add a new merchant (future transactions from it are auto-categorised); pass `id` to update an existing merchant's name, category, or flags.",
43046
- inputSchema: {
43047
- id: exports_external.string().optional().describe("Merchant UUID — pass to update, omit to create"),
43048
- rawId: exports_external.string().optional().describe("Raw identifier — UPI ID or normalised name (required when creating)"),
43049
- cleanName: exports_external.string().optional().describe('Human-friendly name (e.g. "DMart")'),
43050
- categorySlug: exports_external.string().optional().describe("Category slug, e.g. 'food_delivery'"),
43051
- isPerson: exports_external.boolean().optional(),
43052
- isRecurring: exports_external.boolean().optional(),
43053
- notes: exports_external.string().optional()
43054
- }
43055
- }, async ({ id, categorySlug, ...rest }) => {
43056
- const payload = { ...rest };
43057
- if (categorySlug) {
43058
- const slugMap = await categorySlugToId(client);
43059
- const categoryId = slugMap.get(categorySlug);
43060
- if (!categoryId) {
43061
- throw new Error(`Unknown categorySlug "${categorySlug}". Use list_categories to see valid slugs.`);
43062
- }
43063
- payload.categoryId = categoryId;
43064
- }
43065
- const data = id ? await client.patch(`/api/merchants/${id}`, payload) : await client.post("/api/merchants", payload);
43121
+ };
43122
+ const data = id ? await client.patch(`/api/investments/fds/${id}`, fdBody) : await client.post("/api/investments/fds", fdBody);
43066
43123
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
43067
43124
  });
43068
- server.registerTool("list_merchants", {
43069
- 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.",
43125
+ server.registerTool("delete_investment", {
43126
+ description: "Delete a SIP or fixed deposit by id. PERMANENT (FDs are soft-deleted) — confirm with the user first.",
43070
43127
  inputSchema: {
43071
- rawId: exports_external.string().optional().describe("Raw identifier to look up a single merchant; omit to list all")
43072
- }
43073
- }, async ({ rawId }) => {
43074
- if (crypto3 && rawId) {
43075
- const data2 = await client.get("/api/merchants");
43076
- if (data2.data)
43077
- await decryptMerchantFields(data2.data, crypto3.key);
43078
- const needle = rawId.toLowerCase();
43079
- const merchants = (data2.data ?? []).filter((m) => String(m.rawId ?? "").toLowerCase() === needle || String(m.cleanName ?? "").toLowerCase().includes(needle));
43080
- return {
43081
- content: [{ type: "text", text: JSON.stringify({ ...data2, data: merchants }) }]
43082
- };
43128
+ type: exports_external.enum(["sip", "fixed_deposit"]),
43129
+ id: exports_external.string().describe("SIP or FD UUID")
43083
43130
  }
43084
- const data = rawId ? await client.get("/api/merchants", {
43085
- search: rawId
43086
- }) : await client.get("/api/merchants");
43087
- if (crypto3 && data.data)
43088
- await decryptMerchantFields(data.data, crypto3.key);
43131
+ }, async ({ type, id }) => {
43132
+ const data = type === "sip" ? await client.delete(`/api/investments/sips/${id}`) : await client.delete(`/api/investments/fds/${id}`);
43089
43133
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
43090
43134
  });
43091
- server.registerTool("delete_merchant", {
43092
- 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.",
43093
- inputSchema: {
43094
- id: exports_external.string().optional().describe("Single merchant UUID"),
43095
- ids: exports_external.array(exports_external.string()).min(1).optional().describe("Multiple merchant UUIDs (bulk delete)")
43096
- }
43097
- }, async ({ id, ids }) => {
43098
- if (!id && (!ids || ids.length === 0)) {
43099
- throw new Error("Provide either id or ids");
43100
- }
43101
- const data = ids ? await client.post("/api/merchants/bulk-delete", { ids }) : await client.delete(`/api/merchants/${id}`);
43135
+ server.registerTool("sync_zerodha", {
43136
+ description: "Zerodha broker link. action='token_health' → check whether the access token is still valid (expired tokens need re-login in the web app); action='sync' → pull latest stocks, mutual funds and SIPs.",
43137
+ inputSchema: { action: exports_external.enum(["token_health", "sync"]) }
43138
+ }, async ({ action }) => {
43139
+ const data = action === "sync" ? await client.post("/api/investments/zerodha/sync") : await client.get("/api/investments/zerodha/token-health");
43102
43140
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
43103
43141
  });
43104
43142
  }
43105
43143
 
43106
- // src/tools/net-worth.ts
43107
- function registerNetWorthTools(server, client) {
43108
- server.registerTool("get_net_worth", {
43109
- description: "Current net worth snapshot and history (up to 12 months). Includes liquid cash, investments, emergency fund, liabilities.",
43110
- inputSchema: {}
43111
- }, async () => {
43112
- const data = await client.get("/api/net-worth");
43113
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43114
- });
43115
- server.registerTool("update_net_worth", {
43116
- 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.",
43117
- inputSchema: {
43118
- mode: exports_external.enum(["manual", "auto"]).default("manual"),
43119
- month: exports_external.number().min(1).max(12),
43120
- year: exports_external.number().min(2020).max(2100),
43121
- liquidCash: exports_external.string().optional(),
43122
- investments: exports_external.string().optional(),
43123
- emergencyFund: exports_external.string().optional(),
43124
- liabilities: exports_external.string().optional(),
43125
- breakdown: exports_external.record(exports_external.string(), exports_external.string()).optional()
43144
+ // ../../node_modules/.bun/@typesafe-ai+sdk@0.6.0/node_modules/@typesafe-ai/sdk/dist/index.mjs
43145
+ var requestIdFrom = (headers) => headers.get("x-typesafe-request-id") ?? undefined;
43146
+ var APIPromise = class APIPromise2 extends Promise {
43147
+ #responsePromise;
43148
+ #parseResponse;
43149
+ #parsed;
43150
+ constructor(responsePromise, parseResponse) {
43151
+ super((resolve) => resolve(undefined));
43152
+ this.#responsePromise = responsePromise;
43153
+ this.#parseResponse = parseResponse;
43154
+ }
43155
+ asResponse() {
43156
+ return this.#responsePromise;
43157
+ }
43158
+ async withResponse() {
43159
+ const [data, response] = await Promise.all([this.#parse(), this.#responsePromise]);
43160
+ return {
43161
+ data,
43162
+ response,
43163
+ requestId: requestIdFrom(response.headers)
43164
+ };
43165
+ }
43166
+ map(fn) {
43167
+ return new APIPromise2(this.#responsePromise, () => this.#parse().then(fn));
43168
+ }
43169
+ #parse() {
43170
+ this.#parsed ??= this.#responsePromise.then(this.#parseResponse);
43171
+ return this.#parsed;
43172
+ }
43173
+ then(onfulfilled, onrejected) {
43174
+ return this.#parse().then(onfulfilled, onrejected);
43175
+ }
43176
+ catch(onrejected) {
43177
+ return this.#parse().catch(onrejected);
43178
+ }
43179
+ finally(onfinally) {
43180
+ return this.#parse().finally(onfinally);
43181
+ }
43182
+ };
43183
+ var ENV = {
43184
+ apiKey: "TYPESAFE_API_KEY",
43185
+ baseURL: "TYPESAFE_BASE_URL",
43186
+ defaultModel: "TYPESAFE_DEFAULT_MODEL",
43187
+ logLevel: "TYPESAFE_LOG_LEVEL"
43188
+ };
43189
+ var readEnv = (name) => {
43190
+ if (typeof process === "undefined" || !process.env)
43191
+ return;
43192
+ return process.env[name]?.trim() || undefined;
43193
+ };
43194
+ var fromCodeOrEnv = (fromCode, envVar) => fromCode ?? readEnv(envVar);
43195
+ var range = (from, to) => Array.from({ length: to - from }, (_, i) => from + i);
43196
+ var DEFAULT_RETRY_POLICY = {
43197
+ maxRetries: 2,
43198
+ backoffInitialMs: 500,
43199
+ backoffMaxMs: 5000,
43200
+ backoffJitter: 0.25,
43201
+ httpStatuses: /* @__PURE__ */ new Set([
43202
+ 408,
43203
+ 429,
43204
+ ...range(500, 600)
43205
+ ]),
43206
+ respectRetryAfter: true,
43207
+ maxRetryAfterMs: 60000,
43208
+ apiConnectionError: true,
43209
+ apiTimeoutError: true
43210
+ };
43211
+ DEFAULT_RETRY_POLICY.maxRetries;
43212
+ var isRetryableStatus = (status, policy = DEFAULT_RETRY_POLICY) => policy.httpStatuses.has(status);
43213
+ var parseRetryAfter = (headers, now = Date.now()) => {
43214
+ const ms = Number(headers.get("retry-after-ms"));
43215
+ if (headers.has("retry-after-ms") && Number.isFinite(ms) && ms >= 0)
43216
+ return ms;
43217
+ const raw = headers.get("retry-after");
43218
+ if (raw === null)
43219
+ return;
43220
+ const seconds = Number(raw);
43221
+ if (Number.isFinite(seconds))
43222
+ return seconds >= 0 ? seconds * 1000 : undefined;
43223
+ const date4 = Date.parse(raw);
43224
+ if (!Number.isNaN(date4))
43225
+ return Math.max(0, date4 - now);
43226
+ };
43227
+ var retryDelayMs = (attempt, headers, policy = DEFAULT_RETRY_POLICY, random = Math.random) => {
43228
+ if (policy.respectRetryAfter && headers !== undefined) {
43229
+ const retryAfter = parseRetryAfter(headers);
43230
+ if (retryAfter !== undefined && retryAfter <= policy.maxRetryAfterMs)
43231
+ return retryAfter;
43232
+ }
43233
+ const exponential = Math.min(policy.backoffInitialMs * 2 ** attempt, policy.backoffMaxMs);
43234
+ return Math.round(exponential * (1 - random() * policy.backoffJitter));
43235
+ };
43236
+ var sleep = (ms, signal) => new Promise((resolve, reject) => {
43237
+ if (signal?.aborted)
43238
+ return reject(signal.reason);
43239
+ const onAbort = () => {
43240
+ clearTimeout(timer);
43241
+ reject(signal?.reason);
43242
+ };
43243
+ const timer = setTimeout(() => {
43244
+ signal?.removeEventListener("abort", onAbort);
43245
+ resolve();
43246
+ }, ms);
43247
+ signal?.addEventListener("abort", onAbort, { once: true });
43248
+ });
43249
+ var TypeSafeError = class extends Error {
43250
+ constructor(message, options) {
43251
+ super(message, options);
43252
+ this.name = new.target.name;
43253
+ }
43254
+ };
43255
+ var isRecord = (value) => typeof value === "object" && value !== null;
43256
+ var extractMessage = (body) => {
43257
+ if (typeof body === "string")
43258
+ return body || undefined;
43259
+ if (!isRecord(body))
43260
+ return;
43261
+ const { error: error2, message, detail } = body;
43262
+ if (typeof error2 === "string")
43263
+ return error2;
43264
+ if (isRecord(error2) && typeof error2.message === "string")
43265
+ return error2.message;
43266
+ if (typeof message === "string")
43267
+ return message;
43268
+ if (typeof detail === "string")
43269
+ return detail;
43270
+ if (isRecord(detail) && typeof detail.message === "string")
43271
+ return detail.message;
43272
+ if (Array.isArray(detail))
43273
+ return describeValidationErrors(detail);
43274
+ };
43275
+ var describeValidationErrors = (errors4) => {
43276
+ const parts = errors4.flatMap((e) => {
43277
+ if (!isRecord(e) || typeof e.msg !== "string")
43278
+ return [];
43279
+ const loc = Array.isArray(e.loc) ? e.loc.filter((x) => x !== "body").join(".") : "";
43280
+ return [loc ? `${loc}: ${e.msg}` : e.msg];
43281
+ });
43282
+ return parts.length > 0 ? parts.join("; ") : undefined;
43283
+ };
43284
+ var MAX_RAW_BODY_IN_MESSAGE = 200;
43285
+ var APIError = class APIError2 extends TypeSafeError {
43286
+ status;
43287
+ headers;
43288
+ body;
43289
+ requestId;
43290
+ constructor(status, body, headers, message) {
43291
+ super(message ?? APIError2.describe(status, body));
43292
+ this.status = status;
43293
+ this.body = body;
43294
+ this.headers = headers;
43295
+ this.requestId = requestIdFrom(headers);
43296
+ }
43297
+ static describe(status, body) {
43298
+ const detail = extractMessage(body);
43299
+ if (detail)
43300
+ return `${status} ${detail}`;
43301
+ if (body === undefined)
43302
+ return `${status} status code (no body)`;
43303
+ const raw = typeof body === "string" ? body : JSON.stringify(body);
43304
+ return `${status} ${raw.length > MAX_RAW_BODY_IN_MESSAGE ? `${raw.slice(0, MAX_RAW_BODY_IN_MESSAGE)}…` : raw}`;
43305
+ }
43306
+ static fromResponse(status, body, headers) {
43307
+ if (status === 400)
43308
+ return new BadRequestError(status, body, headers);
43309
+ if (status === 401)
43310
+ return new AuthenticationError(status, body, headers);
43311
+ if (status === 403)
43312
+ return new PermissionDeniedError(status, body, headers);
43313
+ if (status === 404)
43314
+ return new NotFoundError(status, body, headers);
43315
+ if (status === 422)
43316
+ return new UnprocessableEntityError(status, body, headers);
43317
+ if (status === 429)
43318
+ return new RateLimitError(status, body, headers);
43319
+ if (status >= 500)
43320
+ return new InternalServerError(status, body, headers);
43321
+ return new APIError2(status, body, headers);
43322
+ }
43323
+ };
43324
+ var BadRequestError = class extends APIError {
43325
+ };
43326
+ var AuthenticationError = class extends APIError {
43327
+ };
43328
+ var PermissionDeniedError = class extends APIError {
43329
+ };
43330
+ var NotFoundError = class extends APIError {
43331
+ };
43332
+ var UnprocessableEntityError = class extends APIError {
43333
+ };
43334
+ var RateLimitError = class extends APIError {
43335
+ retryAfterMs = parseRetryAfter(this.headers);
43336
+ };
43337
+ var InternalServerError = class extends APIError {
43338
+ };
43339
+ var APIConnectionError = class extends TypeSafeError {
43340
+ constructor(message = "Connection error.", options) {
43341
+ super(message, options);
43342
+ }
43343
+ };
43344
+ var APITimeoutError = class extends APIConnectionError {
43345
+ timeoutMs;
43346
+ constructor(timeoutMs, options) {
43347
+ super(`Request timed out after ${timeoutMs}ms.`, options);
43348
+ this.timeoutMs = timeoutMs;
43349
+ }
43350
+ };
43351
+ var APIUserAbortError = class extends TypeSafeError {
43352
+ constructor(message = "Request was aborted.", options) {
43353
+ super(message, options);
43354
+ }
43355
+ };
43356
+ var LOG_LEVELS = [
43357
+ "debug",
43358
+ "info",
43359
+ "warn",
43360
+ "error",
43361
+ "off"
43362
+ ];
43363
+ var DEFAULT_LOG_LEVEL = "warn";
43364
+ var isLogLevel = (value) => LOG_LEVELS.includes(value);
43365
+ var parseLogLevel = (value, source) => {
43366
+ if (isLogLevel(value))
43367
+ return value;
43368
+ throw new TypeSafeError(`Invalid log level "${value}" from ${source}. Expected one of: ${LOG_LEVELS.join(", ")}.`);
43369
+ };
43370
+ var PREFIX = "[typesafe-sdk]";
43371
+ var consoleLogger = {
43372
+ debug: (message, ...args) => console.debug(`${PREFIX} ${message}`, ...args),
43373
+ info: (message, ...args) => console.info(`${PREFIX} ${message}`, ...args),
43374
+ warn: (message, ...args) => console.warn(`${PREFIX} ${message}`, ...args),
43375
+ error: (message, ...args) => console.error(`${PREFIX} ${message}`, ...args)
43376
+ };
43377
+ var RANK = {
43378
+ debug: 0,
43379
+ info: 1,
43380
+ warn: 2,
43381
+ error: 3,
43382
+ off: 4
43383
+ };
43384
+ var drop = () => {};
43385
+ var withLevel = (sink, level) => {
43386
+ const enabled = (at) => RANK[at] >= RANK[level];
43387
+ return {
43388
+ debug: enabled("debug") ? (message, ...args) => sink.debug(message, ...args) : drop,
43389
+ info: enabled("info") ? (message, ...args) => sink.info(message, ...args) : drop,
43390
+ warn: enabled("warn") ? (message, ...args) => sink.warn(message, ...args) : drop,
43391
+ error: enabled("error") ? (message, ...args) => sink.error(message, ...args) : drop
43392
+ };
43393
+ };
43394
+ var KEY_HEADERS = /* @__PURE__ */ new Set([
43395
+ "authorization",
43396
+ "proxy-authorization",
43397
+ "x-api-key"
43398
+ ]);
43399
+ var OPAQUE_HEADERS = /* @__PURE__ */ new Set(["cookie", "set-cookie"]);
43400
+ var redactKey = (value) => {
43401
+ const [scheme, secret] = value.includes(" ") ? value.split(/\s+/, 2) : [undefined, value];
43402
+ const tail = secret && secret.length > 8 ? secret.slice(-4) : "";
43403
+ return `${scheme ? `${scheme} ` : ""}***${tail}`;
43404
+ };
43405
+ var redact = (name, value) => {
43406
+ const lower = name.toLowerCase();
43407
+ if (KEY_HEADERS.has(lower))
43408
+ return redactKey(value);
43409
+ if (OPAQUE_HEADERS.has(lower))
43410
+ return "***";
43411
+ return value;
43412
+ };
43413
+ var redactHeaders = (headers) => Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, redact(name, value)]));
43414
+ var choice = (instructions, criteria) => {
43415
+ if (Array.isArray(criteria))
43416
+ throw new TypeSafeError("Choice criteria must be a map of labels to descriptions, not a list.");
43417
+ return {
43418
+ type: "choice",
43419
+ instructions,
43420
+ criteria
43421
+ };
43422
+ };
43423
+ var validateQuestions = (questions) => {
43424
+ if (Object.keys(questions).length === 0)
43425
+ throw new TypeSafeError("At least one question is required.");
43426
+ for (const [name, question] of Object.entries(questions)) {
43427
+ if (question.type !== "score")
43428
+ continue;
43429
+ if (!Array.isArray(question.criteria))
43430
+ throw new TypeSafeError(`Score question "${name}" has criteria that are not a list; score criteria must be a list of descriptions indexed by score from zero.`);
43431
+ if (question.criteria.length < 2)
43432
+ throw new TypeSafeError(`Score question "${name}" has ${question.criteria.length} criteria; at least two scores are required.`);
43433
+ }
43434
+ };
43435
+ var Models = class {
43436
+ #transport;
43437
+ constructor(transport) {
43438
+ this.#transport = transport;
43439
+ }
43440
+ list(options = {}) {
43441
+ return this.#transport.request("GET", "/v1/models", options).map(unwrapModels);
43442
+ }
43443
+ };
43444
+ var unwrapModels = (wire) => {
43445
+ if (Array.isArray(wire?.models))
43446
+ return wire.models;
43447
+ throw new TypeSafeError("Unexpected response shape from GET /v1/models; expected { models: [...] }.");
43448
+ };
43449
+ var g = globalThis;
43450
+ var isBrowser = () => typeof g.window !== "undefined" && typeof g.window.document !== "undefined" && typeof g.navigator !== "undefined";
43451
+ var describeRuntime = () => {
43452
+ const platform = g.process?.platform && g.process?.arch ? ` (${g.process.platform}; ${g.process.arch})` : "";
43453
+ if (g.Bun?.version)
43454
+ return `bun/${g.Bun.version}${platform}`;
43455
+ if (g.Deno?.version?.deno)
43456
+ return `deno/${g.Deno.version.deno}${platform}`;
43457
+ if (g.EdgeRuntime !== undefined)
43458
+ return "vercel-edge";
43459
+ if (g.navigator?.userAgent === "Cloudflare-Workers")
43460
+ return "cloudflare-workers";
43461
+ if (g.process?.versions?.node)
43462
+ return `node/${g.process.versions.node}${platform}`;
43463
+ if (isBrowser())
43464
+ return "browser";
43465
+ return "unknown";
43466
+ };
43467
+ var VERSION2 = "0.6.0";
43468
+ var missingApiKey = () => {
43469
+ throw new TypeSafeError(`No API key was provided. Pass \`apiKey\` to the TypeSafeClient constructor or set the ${ENV.apiKey} environment variable.`);
43470
+ };
43471
+ var missingFetch = () => {
43472
+ throw new TypeSafeError("No global `fetch` is available in this runtime. Pass a `fetch` implementation to the TypeSafeClient constructor.");
43473
+ };
43474
+ var refuseBrowser = () => {
43475
+ throw new TypeSafeError("TypeSafeClient is running in a browser, which would expose your API key to anyone using the page. Call the API from a server instead, or pass `dangerouslyAllowBrowser: true` if you understand the risk.");
43476
+ };
43477
+ var defaultFetch = (input, init) => globalThis.fetch(input, init);
43478
+ var assertNonNegativeInteger = (name, value) => {
43479
+ if (!Number.isInteger(value) || value < 0)
43480
+ throw new TypeSafeError(`\`${name}\` must be a non-negative integer, got ${String(value)}.`);
43481
+ return value;
43482
+ };
43483
+ var assertPositiveMs = (name, value) => {
43484
+ if (!Number.isFinite(value) || value <= 0)
43485
+ throw new TypeSafeError(`\`${name}\` must be a positive number of milliseconds, got ${String(value)}.`);
43486
+ return value;
43487
+ };
43488
+ var assertNonNegativeMs = (name, value) => {
43489
+ if (!Number.isFinite(value) || value < 0)
43490
+ throw new TypeSafeError(`\`${name}\` must be a non-negative number of milliseconds, got ${String(value)}.`);
43491
+ return value;
43492
+ };
43493
+ var assertFraction = (name, value) => {
43494
+ if (!Number.isFinite(value) || value < 0 || value > 1)
43495
+ throw new TypeSafeError(`\`${name}\` must be between 0 and 1, got ${String(value)}.`);
43496
+ return value;
43497
+ };
43498
+ var assertStatusSet = (name, statuses) => {
43499
+ for (const status of statuses)
43500
+ if (!Number.isInteger(status) || status < 100 || status > 999)
43501
+ throw new TypeSafeError(`\`${name}\` must contain HTTP status codes, got ${String(status)}.`);
43502
+ return statuses;
43503
+ };
43504
+ var resolveRetryPolicy = (base, overrides) => {
43505
+ const o = overrides ?? {};
43506
+ return {
43507
+ maxRetries: o.maxRetries === undefined ? base.maxRetries : assertNonNegativeInteger("retry.maxRetries", o.maxRetries),
43508
+ backoffInitialMs: o.backoffInitialMs === undefined ? base.backoffInitialMs : assertNonNegativeMs("retry.backoffInitialMs", o.backoffInitialMs),
43509
+ backoffMaxMs: o.backoffMaxMs === undefined ? base.backoffMaxMs : assertNonNegativeMs("retry.backoffMaxMs", o.backoffMaxMs),
43510
+ backoffJitter: o.backoffJitter === undefined ? base.backoffJitter : assertFraction("retry.backoffJitter", o.backoffJitter),
43511
+ httpStatuses: new Set(o.httpStatuses === undefined ? base.httpStatuses : assertStatusSet("retry.httpStatuses", o.httpStatuses)),
43512
+ respectRetryAfter: o.respectRetryAfter ?? base.respectRetryAfter,
43513
+ maxRetryAfterMs: o.maxRetryAfterMs === undefined ? base.maxRetryAfterMs : assertNonNegativeMs("retry.maxRetryAfterMs", o.maxRetryAfterMs),
43514
+ apiConnectionError: o.apiConnectionError ?? base.apiConnectionError,
43515
+ apiTimeoutError: o.apiTimeoutError ?? base.apiTimeoutError
43516
+ };
43517
+ };
43518
+ var isRetryableError = (err, policy) => {
43519
+ if (err instanceof APITimeoutError)
43520
+ return policy.apiTimeoutError;
43521
+ if (err instanceof APIConnectionError)
43522
+ return policy.apiConnectionError;
43523
+ return false;
43524
+ };
43525
+ var resolveLogLevel = (fromCode) => {
43526
+ if (fromCode !== undefined)
43527
+ return parseLogLevel(fromCode, "the `logLevel` option");
43528
+ const fromEnv = readEnv(ENV.logLevel);
43529
+ if (fromEnv !== undefined)
43530
+ return parseLogLevel(fromEnv, ENV.logLevel);
43531
+ return DEFAULT_LOG_LEVEL;
43532
+ };
43533
+ var stripTrailingSlashes = (url2) => url2.replace(/\/+$/, "");
43534
+ var mergeHeaders = (...sources) => {
43535
+ const entries = /* @__PURE__ */ new Map;
43536
+ for (const source of sources)
43537
+ for (const [name, value] of Object.entries(source))
43538
+ if (value === undefined)
43539
+ entries.delete(name.toLowerCase());
43540
+ else
43541
+ entries.set(name.toLowerCase(), [name, value]);
43542
+ return Object.fromEntries(entries.values());
43543
+ };
43544
+ var bufferResponse = async (response, signal) => {
43545
+ const reader = response.clone().body?.getReader();
43546
+ if (!reader)
43547
+ return;
43548
+ const cancel = () => {
43549
+ reader.cancel(signal.reason).catch(() => {});
43550
+ response.body?.cancel(signal.reason).catch(() => {});
43551
+ };
43552
+ signal.addEventListener("abort", cancel, { once: true });
43553
+ try {
43554
+ if (signal.aborted)
43555
+ cancel();
43556
+ signal.throwIfAborted();
43557
+ while (!(await reader.read()).done)
43558
+ signal.throwIfAborted();
43559
+ signal.throwIfAborted();
43560
+ } finally {
43561
+ signal.removeEventListener("abort", cancel);
43562
+ reader.releaseLock();
43563
+ }
43564
+ };
43565
+ var RUNTIME = describeRuntime();
43566
+ var TypeSafeClient = class {
43567
+ #apiKey;
43568
+ baseURL;
43569
+ defaultModel;
43570
+ logLevel;
43571
+ logger;
43572
+ retry;
43573
+ timeout;
43574
+ defaultHeaders;
43575
+ fetch;
43576
+ models;
43577
+ #requestCount = 0;
43578
+ constructor(config2 = {}) {
43579
+ if (isBrowser() && !config2.dangerouslyAllowBrowser)
43580
+ refuseBrowser();
43581
+ this.#apiKey = fromCodeOrEnv(config2.apiKey, ENV.apiKey) ?? missingApiKey();
43582
+ this.baseURL = stripTrailingSlashes(fromCodeOrEnv(config2.baseURL, ENV.baseURL) ?? "https://api.typesafe.ai");
43583
+ this.defaultModel = fromCodeOrEnv(config2.defaultModel, ENV.defaultModel) ?? "jev-latest";
43584
+ this.logLevel = resolveLogLevel(config2.logLevel);
43585
+ this.logger = withLevel(config2.logger ?? consoleLogger, this.logLevel);
43586
+ this.retry = resolveRetryPolicy(DEFAULT_RETRY_POLICY, config2.retry);
43587
+ this.timeout = assertPositiveMs("timeout", config2.timeout ?? 1e4);
43588
+ this.defaultHeaders = { ...config2.defaultHeaders };
43589
+ if (config2.fetch === undefined && typeof globalThis.fetch !== "function")
43590
+ missingFetch();
43591
+ this.fetch = config2.fetch ?? defaultFetch;
43592
+ const transport = {
43593
+ request: (method, path, options) => this.#request(method, path, options),
43594
+ defaultModel: this.defaultModel
43595
+ };
43596
+ this.models = new Models(transport);
43597
+ }
43598
+ systemOne(request, options = {}) {
43599
+ validateQuestions(request.questions);
43600
+ const body = {
43601
+ ...request,
43602
+ model: request.model ?? this.defaultModel
43603
+ };
43604
+ return this.#request("POST", "/v1/systemone", {
43605
+ ...options,
43606
+ body
43607
+ });
43608
+ }
43609
+ #request(method, path, options = {}) {
43610
+ const resolved = {
43611
+ method,
43612
+ path,
43613
+ body: options.body,
43614
+ headers: mergeHeaders(this.defaultHeaders, options.headers ?? {}),
43615
+ signal: options.signal,
43616
+ timeout: options.timeout === undefined ? this.timeout : assertPositiveMs("timeout", options.timeout),
43617
+ retry: resolveRetryPolicy(this.retry, options.retry)
43618
+ };
43619
+ const tag = `#${++this.#requestCount} ${method} ${path}`;
43620
+ return new APIPromise(this.fetchWithRetries(tag, resolved), async (res) => {
43621
+ const parsed = await parseBody(res);
43622
+ this.logger.debug(`${tag} <- body`, parsed);
43623
+ return parsed;
43624
+ });
43625
+ }
43626
+ async fetchWithRetries(tag, req) {
43627
+ const url2 = `${this.baseURL}${req.path}`;
43628
+ const headers = mergeHeaders(req.headers, {
43629
+ Authorization: `Bearer ${this.#apiKey}`,
43630
+ Accept: "application/json",
43631
+ "User-Agent": `typesafe-sdk/${VERSION2}`,
43632
+ "X-TypeSafe-SDK": `typesafe-sdk/${VERSION2}`,
43633
+ "X-TypeSafe-Runtime": RUNTIME,
43634
+ "Content-Type": req.body === undefined ? undefined : "application/json",
43635
+ "X-TypeSafe-Retry-Count": undefined
43636
+ });
43637
+ const body = req.body === undefined ? undefined : JSON.stringify(req.body);
43638
+ for (let attempt = 0;; attempt++) {
43639
+ const retriesLeft = req.retry.maxRetries - attempt;
43640
+ const attemptHeaders = attempt === 0 ? headers : {
43641
+ ...headers,
43642
+ "X-TypeSafe-Retry-Count": String(attempt)
43643
+ };
43644
+ this.logger.debug(`${tag} -> ${url2}`, {
43645
+ headers: redactHeaders(attemptHeaders),
43646
+ body: req.body
43647
+ });
43648
+ const started = Date.now();
43649
+ let res;
43650
+ try {
43651
+ res = await this.attempt(tag, url2, {
43652
+ method: req.method,
43653
+ headers: attemptHeaders,
43654
+ body
43655
+ }, req);
43656
+ } catch (err) {
43657
+ if (err instanceof APIUserAbortError || retriesLeft <= 0)
43658
+ throw err;
43659
+ if (!isRetryableError(err, req.retry))
43660
+ throw err;
43661
+ await this.backOff(tag, attempt, retriesLeft, err.message, undefined, req);
43662
+ continue;
43663
+ }
43664
+ const requestId = requestIdFrom(res.headers);
43665
+ this.logger.info(`${tag} <- ${res.status} in ${Date.now() - started}ms${requestId ? ` (request ${requestId})` : ""}`);
43666
+ if (res.ok)
43667
+ return res;
43668
+ const errorBody = await parseBody(res);
43669
+ this.logger.debug(`${tag} <- error body`, errorBody);
43670
+ const error2 = APIError.fromResponse(res.status, errorBody, res.headers);
43671
+ if (retriesLeft <= 0 || !isRetryableStatus(res.status, req.retry))
43672
+ throw error2;
43673
+ await this.backOff(tag, attempt, retriesLeft, `${res.status}`, res.headers, req);
43126
43674
  }
43127
- }, async ({ mode, ...body }) => {
43128
- let data;
43129
- if (mode === "auto") {
43130
- data = await client.post("/api/net-worth/auto", { month: body.month, year: body.year });
43131
- } else {
43132
- const liquidCash = parseFloat(body.liquidCash ?? "0");
43133
- const investments = parseFloat(body.investments ?? "0");
43134
- const emergencyFund = parseFloat(body.emergencyFund ?? "0");
43135
- const liabilities = parseFloat(body.liabilities ?? "0");
43136
- const totalNetWorth = (liquidCash + investments + emergencyFund - liabilities).toFixed(2);
43137
- data = await client.post("/api/net-worth", {
43138
- ...body,
43139
- emergencyFund: body.emergencyFund ?? "0",
43140
- totalNetWorth
43675
+ }
43676
+ async attempt(tag, url2, init, { signal, timeout }) {
43677
+ const controller = new AbortController;
43678
+ const abortFromCaller = () => controller.abort(signal?.reason);
43679
+ if (signal?.aborted)
43680
+ abortFromCaller();
43681
+ signal?.addEventListener("abort", abortFromCaller, { once: true });
43682
+ let timedOut = false;
43683
+ const timer = setTimeout(() => {
43684
+ timedOut = true;
43685
+ controller.abort();
43686
+ }, timeout);
43687
+ const started = Date.now();
43688
+ const elapsed = () => `${Date.now() - started}ms`;
43689
+ try {
43690
+ const response = await this.fetch(url2, {
43691
+ ...init,
43692
+ signal: controller.signal
43141
43693
  });
43694
+ await bufferResponse(response, controller.signal);
43695
+ return response;
43696
+ } catch (err) {
43697
+ if (signal?.aborted) {
43698
+ this.logger.info(`${tag} aborted by caller after ${elapsed()}`);
43699
+ throw new APIUserAbortError(undefined, { cause: err });
43700
+ }
43701
+ if (timedOut) {
43702
+ this.logger.info(`${tag} timed out after ${elapsed()}`);
43703
+ throw new APITimeoutError(timeout, { cause: err });
43704
+ }
43705
+ this.logger.info(`${tag} connection error after ${elapsed()}`, err);
43706
+ throw new APIConnectionError(err instanceof Error ? `Connection error: ${err.message}` : undefined, { cause: err });
43707
+ } finally {
43708
+ clearTimeout(timer);
43709
+ signal?.removeEventListener("abort", abortFromCaller);
43142
43710
  }
43143
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43144
- });
43145
- }
43146
-
43147
- // src/tools/persons.ts
43148
- function registerPersonTools(server, client, crypto3) {
43149
- server.registerTool("upsert_person", {
43150
- 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.",
43151
- inputSchema: {
43152
- id: exports_external.string().optional().describe("Person UUID — pass to update, omit to create"),
43153
- name: exports_external.string().optional().describe('Display name (e.g. "Priya") — required when creating'),
43154
- upiHandles: exports_external.array(exports_external.string()).optional().describe("UPI ids the person uses"),
43155
- phoneNumbers: exports_external.array(exports_external.string()).optional(),
43156
- relationship: exports_external.string().optional().describe('e.g. "sister", "friend", "landlord"'),
43157
- notes: exports_external.string().optional()
43158
- }
43159
- }, async ({ id, ...updates }) => {
43160
- if (crypto3) {
43161
- if (id) {
43162
- const existing = await client.get(`/api/persons/${id}`);
43163
- const personData = existing.data;
43164
- let current;
43165
- if (personData.cipher) {
43166
- current = await decryptPersonCipher(personData.cipher, crypto3.key);
43167
- } else {
43168
- current = {
43169
- name: personData.name,
43170
- upiHandles: personData.upiHandles,
43171
- phoneNumbers: personData.phoneNumbers,
43172
- relationship: personData.relationship,
43173
- notes: personData.notes
43174
- };
43175
- }
43176
- const merged = {
43177
- name: updates.name ?? current.name,
43178
- upiHandles: updates.upiHandles ?? current.upiHandles,
43179
- phoneNumbers: updates.phoneNumbers ?? current.phoneNumbers,
43180
- relationship: updates.relationship ?? current.relationship ?? null,
43181
- notes: updates.notes ?? current.notes ?? null
43182
- };
43183
- const payload2 = await encryptPersonCipher(merged, crypto3.key);
43184
- const data3 = await client.patch(`/api/persons/${id}`, payload2);
43185
- return { content: [{ type: "text", text: JSON.stringify(data3) }] };
43186
- }
43187
- if (!updates.name)
43188
- throw new Error("name is required when creating a person");
43189
- const fields = {
43190
- name: updates.name,
43191
- upiHandles: updates.upiHandles ?? [],
43192
- phoneNumbers: updates.phoneNumbers ?? [],
43193
- relationship: updates.relationship ?? null,
43194
- notes: updates.notes ?? null
43195
- };
43196
- const payload = await encryptPersonCipher(fields, crypto3.key);
43197
- const data2 = await client.post("/api/persons", payload);
43198
- return { content: [{ type: "text", text: JSON.stringify(data2) }] };
43199
- }
43200
- const data = id ? await client.patch(`/api/persons/${id}`, updates) : await client.post("/api/persons", updates);
43201
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43202
- });
43203
- server.registerTool("list_persons", {
43204
- description: "List all persons in the current household. Pass `id` to get a single person by id instead of listing all.",
43205
- inputSchema: {
43206
- id: exports_external.string().optional().describe("Person UUID to fetch a single person; omit to list all")
43207
- }
43208
- }, async ({ id }) => {
43209
- if (id) {
43210
- const data2 = await client.get(`/api/persons/${id}`);
43211
- if (crypto3 && data2.data.cipher) {
43212
- const fields = await decryptPersonCipher(data2.data.cipher, crypto3.key);
43213
- Object.assign(data2.data, fields, { cipher: null });
43214
- }
43215
- return { content: [{ type: "text", text: JSON.stringify(data2) }] };
43216
- }
43217
- const data = await client.get("/api/persons");
43218
- if (crypto3 && data.data) {
43219
- for (const person of data.data) {
43220
- const cipher = person.cipher;
43221
- if (cipher) {
43222
- try {
43223
- const fields = await decryptPersonCipher(cipher, crypto3.key);
43224
- Object.assign(person, fields, { cipher: null });
43225
- } catch {}
43226
- }
43227
- }
43711
+ }
43712
+ async backOff(tag, attempt, retriesLeft, reason, headers, { retry, signal }) {
43713
+ const delay = retryDelayMs(attempt, headers, retry);
43714
+ const nth = attempt + 1;
43715
+ const total = attempt + retriesLeft;
43716
+ this.logger.info(`${tag} retrying in ${delay}ms (retry ${nth}/${total}) after ${reason}`);
43717
+ try {
43718
+ await sleep(delay, signal);
43719
+ } catch (err) {
43720
+ this.logger.info(`${tag} aborted by caller while waiting to retry`);
43721
+ throw new APIUserAbortError(undefined, { cause: err });
43228
43722
  }
43229
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43230
- });
43231
- server.registerTool("delete_person", {
43232
- description: "Delete a person. Returns 409 if any transactions still reference this person.",
43233
- inputSchema: { id: exports_external.string().describe("Person UUID") }
43234
- }, async ({ id }) => {
43235
- const data = await client.delete(`/api/persons/${id}`);
43236
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43237
- });
43238
- }
43239
-
43240
- // src/tools/profile.ts
43241
- function registerProfileTools(server, client) {
43242
- server.registerTool("get_profile", {
43243
- description: "Get the current user's profile (name, email, owner).",
43244
- inputSchema: {}
43245
- }, async () => {
43246
- const data = await client.get("/api/profile");
43247
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43248
- });
43249
- server.registerTool("update_profile", {
43250
- description: "Update your display name.",
43251
- inputSchema: {
43252
- name: exports_external.string().min(1).describe("Display name")
43723
+ }
43724
+ };
43725
+ var parseBody = async (res) => {
43726
+ const text = await res.text();
43727
+ if (text.length === 0)
43728
+ return;
43729
+ if ((res.headers.get("content-type") ?? "").includes("application/json"))
43730
+ try {
43731
+ return JSON.parse(text);
43732
+ } catch {
43733
+ return text;
43253
43734
  }
43254
- }, async (body) => {
43255
- const data = await client.patch("/api/profile", body);
43256
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43257
- });
43258
- }
43259
-
43260
- // src/tools/push.ts
43261
- function registerPushTools(server, client) {
43262
- server.registerTool("list_push_subscriptions", {
43263
- description: "List the household's registered web-push subscriptions (browser/device endpoints that receive alert notifications).",
43264
- inputSchema: {}
43265
- }, async () => {
43266
- const data = await client.get("/api/push/subscriptions");
43267
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43268
- });
43269
- server.registerTool("send_test_push", {
43270
- description: "Send a test web-push notification to all of the household's subscriptions. Use to verify push delivery is working.",
43271
- inputSchema: {}
43272
- }, async () => {
43273
- const data = await client.post("/api/push/test");
43274
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43275
- });
43276
- }
43735
+ try {
43736
+ return JSON.parse(text);
43737
+ } catch {
43738
+ return text;
43739
+ }
43740
+ };
43277
43741
 
43278
- // src/tools/recurring.ts
43279
- function registerRecurringTools(server, client) {
43280
- server.registerTool("list_recurring", {
43281
- description: "List all recurring transactions (subscriptions, rent, salaries).",
43282
- inputSchema: {}
43283
- }, async () => {
43284
- const data = await client.get("/api/recurring");
43285
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43286
- });
43287
- server.registerTool("add_recurring", {
43288
- description: "Add a recurring transaction to track subscriptions or regular income.",
43289
- inputSchema: {
43290
- name: exports_external.string(),
43291
- amount: exports_external.string(),
43292
- type: exports_external.enum(["debit", "credit"]),
43293
- frequency: exports_external.enum(["daily", "weekly", "monthly", "yearly"]),
43294
- dayOfMonth: exports_external.number().optional().describe("For monthly — day of month (1-31)"),
43295
- categorySlug: exports_external.string().optional(),
43296
- owner: exports_external.string(),
43297
- startDate: exports_external.string().optional().describe("YYYY-MM-DD"),
43298
- notes: exports_external.string().optional()
43742
+ // src/lib/categories.ts
43743
+ async function loadCategoryIndex(client) {
43744
+ const res = await client.get("/api/categories");
43745
+ const bySlug = new Map;
43746
+ const byId = new Map;
43747
+ const parents = new Set;
43748
+ for (const root of res.data ?? []) {
43749
+ if (root.slug && (root.children?.length ?? 0) > 0)
43750
+ parents.add(root.slug);
43751
+ for (const c of [root, ...root.children ?? []]) {
43752
+ if (!c.slug)
43753
+ continue;
43754
+ bySlug.set(c.slug, { id: c.id, type: c.type });
43755
+ byId.set(c.id, { slug: c.slug, type: c.type });
43299
43756
  }
43300
- }, async (body) => {
43301
- const data = await client.post("/api/recurring", body);
43302
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43303
- });
43757
+ }
43758
+ return { bySlug, byId, parents };
43304
43759
  }
43305
-
43306
- // src/tools/reports.ts
43307
- function registerReportTools(server, client) {
43308
- server.registerTool("generate_monthly_report", {
43309
- description: "Generate a detailed monthly financial report. Computes income, expenses, savings rate, top categories, and insights. Persists to DB.",
43310
- inputSchema: {
43311
- month: exports_external.number().min(1).max(12),
43312
- year: exports_external.number().min(2020).max(2100)
43313
- }
43314
- }, async (body) => {
43315
- const data = await client.post("/api/reports", body);
43316
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43317
- });
43318
- server.registerTool("get_reports", {
43319
- description: "Monthly financial reports for the last N months — income, expenses, savings rate, net worth.",
43320
- inputSchema: {
43321
- months: exports_external.number().optional().describe("Number of months (default: 6)")
43322
- }
43323
- }, async (params) => {
43324
- const data = await client.get("/api/reports", params.months ? { months: params.months } : undefined);
43325
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
43326
- });
43760
+ function requireCategory(index, slug) {
43761
+ const hit = index.bySlug.get(slug);
43762
+ if (!hit) {
43763
+ throw new Error(`Unknown categorySlug "${slug}". Valid: ${[...index.bySlug.keys()].sort().join(", ")}`);
43764
+ }
43765
+ return { ...hit, slug };
43327
43766
  }
43328
43767
 
43329
- // src/tools/transactions.ts
43330
- import { readFile } from "node:fs/promises";
43331
-
43332
43768
  // ../reconciliation/src/aliases.ts
43333
43769
  var MERCHANT_ALIASES = [
43334
43770
  ["Swiggy", ["swiggy", "bundl technologies", "swiggy bundl"]],
@@ -43469,6 +43905,13 @@ function resolveAlias(name) {
43469
43905
  }
43470
43906
  return null;
43471
43907
  }
43908
+ function aliasesMatch(a, b) {
43909
+ const resolvedA = resolveAlias(a);
43910
+ const resolvedB = resolveAlias(b);
43911
+ if (resolvedA === null || resolvedB === null)
43912
+ return false;
43913
+ return resolvedA === resolvedB;
43914
+ }
43472
43915
  // ../../node_modules/.bun/fuzzball@2.2.3/node_modules/fuzzball/dist/esm/fuzzball.esm.min.js
43473
43916
  var e;
43474
43917
  var t = typeof globalThis != "undefined" ? globalThis : typeof window != "undefined" ? window : typeof global != "undefined" ? global : typeof self != "undefined" ? self : {};
@@ -43520,9 +43963,9 @@ var i = function() {
43520
43963
  return _has(u, e3);
43521
43964
  };
43522
43965
  }, SequenceMatcher.prototype.findLongestMatch = function(e2, t2, r2, n2) {
43523
- var o2, a2, i2, s, c, u, l, f, p, h, v, _, d, y, g, m, b, k, j, $, w;
43966
+ var o2, a2, i2, s, c, u, l, f, p, h, v, _, d, y, g2, m, b, k, j, $, w;
43524
43967
  for (o2 = (m = [this.a, this.b, this.b2j, this.isbjunk])[0], a2 = m[1], i2 = m[2], f = m[3], s = (b = [e2, r2, 0])[0], c = b[1], u = b[2], h = {}, l = d = e2;e2 <= t2 ? d < t2 : d > t2; l = e2 <= t2 ? ++d : --d) {
43525
- for (_ = {}, y = 0, g = (k = _has(i2, o2[l]) ? i2[o2[l]] : []).length;y < g; y++)
43968
+ for (_ = {}, y = 0, g2 = (k = _has(i2, o2[l]) ? i2[o2[l]] : []).length;y < g2; y++)
43526
43969
  if (!((p = k[y]) < r2)) {
43527
43970
  if (p >= n2)
43528
43971
  break;
@@ -43540,11 +43983,11 @@ var i = function() {
43540
43983
  u++;
43541
43984
  return [s, c, u];
43542
43985
  }, SequenceMatcher.prototype.getMatchingBlocks = function() {
43543
- var e2, t2, r2, n2, o2, a2, i2, s, c, u, l, f, p, h, v, _, d, y, g, m, b, k, j, $, w, O;
43986
+ var e2, t2, r2, n2, o2, a2, i2, s, c, u, l, f, p, h, v, _, d, y, g2, m, b, k, j, $, w, O;
43544
43987
  if (this.matchingBlocks)
43545
43988
  return this.matchingBlocks;
43546
43989
  for (y = [[0, h = (k = [this.a.length, this.b.length])[0], 0, v = k[1]]], _ = [];y.length; )
43547
- t2 = (j = y.pop())[0], e2 = j[1], n2 = j[2], r2 = j[3], o2 = ($ = g = this.findLongestMatch(t2, e2, n2, r2))[0], s = $[1], (l = $[2]) && (_.push(g), t2 < o2 && n2 < s && y.push([t2, o2, n2, s]), o2 + l < e2 && s + l < r2 && y.push([o2 + l, e2, s + l, r2]));
43990
+ t2 = (j = y.pop())[0], e2 = j[1], n2 = j[2], r2 = j[3], o2 = ($ = g2 = this.findLongestMatch(t2, e2, n2, r2))[0], s = $[1], (l = $[2]) && (_.push(g2), t2 < o2 && n2 < s && y.push([t2, o2, n2, s]), o2 + l < e2 && s + l < r2 && y.push([o2 + l, e2, s + l, r2]));
43548
43991
  for (_.sort(_arrayCmp), a2 = c = f = 0, d = [], m = 0, b = _.length;m < b; m++)
43549
43992
  i2 = (w = _[m])[0], u = w[1], p = w[2], a2 + f === i2 && c + f === u ? f += p : (f && d.push([a2, c, f]), a2 = (O = [i2, u, p])[0], c = O[1], f = O[2]);
43550
43993
  return f && d.push([a2, c, f]), d.push([h, v, 0]), this.matchingBlocks = d;
@@ -43692,11 +44135,11 @@ var v = p || h || Function("return this")();
43692
44135
  var _ = v.Symbol;
43693
44136
  var d = _;
43694
44137
  var y = Object.prototype;
43695
- var g = y.hasOwnProperty;
44138
+ var g2 = y.hasOwnProperty;
43696
44139
  var m = y.toString;
43697
44140
  var b = d ? d.toStringTag : undefined;
43698
44141
  var k = function getRawTag$1(e2) {
43699
- var t2 = g.call(e2, b), r2 = e2[b];
44142
+ var t2 = g2.call(e2, b), r2 = e2[b];
43700
44143
  try {
43701
44144
  e2[b] = undefined;
43702
44145
  var n2 = true;
@@ -44283,8 +44726,8 @@ var Xr = function equalObjects$1(e2, t2, r2, n2, o2, a2) {
44283
44726
  v2 || (v2 = l2 == "constructor");
44284
44727
  }
44285
44728
  if (h2 && !v2) {
44286
- var g2 = e2.constructor, m2 = t2.constructor;
44287
- g2 == m2 || !("constructor" in e2) || !("constructor" in t2) || typeof g2 == "function" && g2 instanceof g2 && typeof m2 == "function" && m2 instanceof m2 || (h2 = false);
44729
+ var g3 = e2.constructor, m2 = t2.constructor;
44730
+ g3 == m2 || !("constructor" in e2) || !("constructor" in t2) || typeof g3 == "function" && g3 instanceof g3 && typeof m2 == "function" && m2 instanceof m2 || (h2 = false);
44288
44731
  }
44289
44732
  return a2.delete(e2), a2.delete(t2), h2;
44290
44733
  };
@@ -44654,15 +45097,15 @@ var qo = function leven2(e2, t2, r2, n2) {
44654
45097
  return 0;
44655
45098
  } else
44656
45099
  f2 = (l2 = r2.wildcards[0].toLowerCase()).charCodeAt(0);
44657
- for (var y2 = 0, g2 = 0;y2 < c2; )
45100
+ for (var y2 = 0, g3 = 0;y2 < c2; )
44658
45101
  a2[y2] = e2.charCodeAt(y2), o2[y2] = ++y2;
44659
45102
  if (i2)
44660
- for (;g2 < u2; )
44661
- for (p2 = t2.charCodeAt(g2), v2 = g2++, h2 = g2, y2 = 0;y2 < c2; y2++)
45103
+ for (;g3 < u2; )
45104
+ for (p2 = t2.charCodeAt(g3), v2 = g3++, h2 = g3, y2 = 0;y2 < c2; y2++)
44662
45105
  _2 = Io.compare(String.fromCharCode(p2), String.fromCharCode(a2[y2])) === 0 || p2 === f2 || a2[y2] === f2 ? v2 : v2 + s2, v2 = o2[y2], h2 = o2[y2] = v2 > h2 ? _2 > h2 ? h2 + 1 : _2 : _2 > v2 ? v2 + 1 : _2;
44663
45106
  else
44664
- for (;g2 < u2; )
44665
- for (p2 = t2.charCodeAt(g2), v2 = g2++, h2 = g2, y2 = 0;y2 < c2; y2++)
45107
+ for (;g3 < u2; )
45108
+ for (p2 = t2.charCodeAt(g3), v2 = g3++, h2 = g3, y2 = 0;y2 < c2; y2++)
44666
45109
  _2 = p2 === a2[y2] || p2 === f2 || a2[y2] === f2 ? v2 : v2 + s2, v2 = o2[y2], h2 = o2[y2] = v2 > h2 ? _2 > h2 ? h2 + 1 : _2 : _2 > v2 ? v2 + 1 : _2;
44667
45110
  return h2;
44668
45111
  }
@@ -44867,7 +45310,7 @@ var Lo = { exports: {} };
44867
45310
  }, n3.isCustomFunc = function(e4) {
44868
45311
  return typeof e4 != "function" || e4.name !== "token_set_ratio" && e4.name !== "partial_token_set_ratio" && e4.name !== "token_sort_ratio" && e4.name !== "partial_token_sort_ratio" && e4.name !== "QRatio" && e4.name !== "WRatio" && e4.name !== "distance" && e4.name !== "partial_ratio";
44869
45312
  }, n3;
44870
- }(Mo._uniq, Mo._uniqWith, l2), y2 = d2.validate, g2 = d2.process_and_sort, m2 = d2.tokenize, b2 = d2.full_process, k2 = d2.clone_and_set_option_defaults, j2 = d2.isCustomFunc, $2 = Lo.exports(k2, p2, QRatio, extract).dedupe;
45313
+ }(Mo._uniq, Mo._uniqWith, l2), y2 = d2.validate, g3 = d2.process_and_sort, m2 = d2.tokenize, b2 = d2.full_process, k2 = d2.clone_and_set_option_defaults, j2 = d2.isCustomFunc, $2 = Lo.exports(k2, p2, QRatio, extract).dedupe;
44871
45314
  function QRatio(e3, t3, r2) {
44872
45315
  var n3 = k2(r2);
44873
45316
  return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? _ratio(e3, t3, n3) : 0;
@@ -44882,11 +45325,11 @@ var Lo = { exports: {} };
44882
45325
  }
44883
45326
  function token_sort_ratio(e3, t3, r2) {
44884
45327
  var n3 = k2(r2);
44885
- return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? (n3.proc_sorted || (e3 = g2(e3), t3 = g2(t3)), _ratio(e3, t3, n3)) : 0;
45328
+ return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? (n3.proc_sorted || (e3 = g3(e3), t3 = g3(t3)), _ratio(e3, t3, n3)) : 0;
44886
45329
  }
44887
45330
  function partial_token_sort_ratio(e3, t3, r2) {
44888
45331
  var n3 = k2(r2);
44889
- return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? (n3.partial = true, n3.proc_sorted || (e3 = g2(e3), t3 = g2(t3)), _partial_ratio(e3, t3, n3)) : 0;
45332
+ return e3 = n3.normalize ? e3.normalize() : e3, t3 = n3.normalize ? t3.normalize() : t3, e3 = n3.full_process ? b2(e3, n3) : e3, t3 = n3.full_process ? b2(t3, n3) : t3, y2(e3) && y2(t3) ? (n3.partial = true, n3.proc_sorted || (e3 = g3(e3), t3 = g3(t3)), _partial_ratio(e3, t3, n3)) : 0;
44890
45333
  }
44891
45334
  function extract(e3, r2, n3) {
44892
45335
  var o3, a3 = k2(n3);
@@ -44916,7 +45359,7 @@ var Lo = { exports: {} };
44916
45359
  i2 || (a3.astral && a3.normalize && (a3.normalize = false, String.prototype.normalize ? (s3 = true, e3 = e3.normalize()) : typeof console !== undefined && console.warn("Normalization not supported in your environment")), e3 = pre_processor(e3, a3), a3.full_process = false, e3.length === 0 && typeof console !== undefined && console.warn("Processed query is empty string"));
44917
45360
  var c2, u3, l3, h3, v3 = [], _3 = false, d3 = false, y3 = false;
44918
45361
  if (a3.scorer.name === "token_sort_ratio" || a3.scorer.name === "partial_token_sort_ratio") {
44919
- var $3 = g2(e3);
45362
+ var $3 = g3(e3);
44920
45363
  d3 = true;
44921
45364
  } else if (a3.scorer.name === "token_set_ratio" || a3.scorer.name === "partial_token_set_ratio") {
44922
45365
  var w3 = m2(e3, a3);
@@ -44931,7 +45374,7 @@ var Lo = { exports: {} };
44931
45374
  }, h3 = function(e4, t3) {
44932
45375
  return t3[1] - e4[1];
44933
45376
  }), f2(r2, function(t3, r3) {
44934
- a3.tokens = undefined, a3.proc_sorted = false, d3 ? (a3.proc_sorted = true, t3 && t3.proc_sorted ? u3 = t3.proc_sorted : (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3), u3 = g2(u3)), c2 = a3.scorer($3, u3, a3)) : y3 ? (u3 = "x", t3 && t3.tokens ? (a3.tokens = [w3, t3.tokens], a3.trySimple && (u3 = pre_processor(a3.processor(t3), a3))) : (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3), a3.tokens = [w3, m2(u3, a3)]), c2 = a3.scorer(e3, u3, a3)) : i2 ? (u3 = a3.processor(t3), c2 = a3.scorer(e3, u3, a3)) : (typeof (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3)) == "string" && u3.length !== 0 || (_3 = true), c2 = a3.scorer(e3, u3, a3)), c2 > a3.cutoff && (a3.returnObjects ? v3.push({ choice: t3, score: c2, key: r3 }) : v3.push([t3, c2, r3]));
45377
+ a3.tokens = undefined, a3.proc_sorted = false, d3 ? (a3.proc_sorted = true, t3 && t3.proc_sorted ? u3 = t3.proc_sorted : (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3), u3 = g3(u3)), c2 = a3.scorer($3, u3, a3)) : y3 ? (u3 = "x", t3 && t3.tokens ? (a3.tokens = [w3, t3.tokens], a3.trySimple && (u3 = pre_processor(a3.processor(t3), a3))) : (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3), a3.tokens = [w3, m2(u3, a3)]), c2 = a3.scorer(e3, u3, a3)) : i2 ? (u3 = a3.processor(t3), c2 = a3.scorer(e3, u3, a3)) : (typeof (u3 = pre_processor(s3 ? a3.processor(t3).normalize() : a3.processor(t3), a3)) == "string" && u3.length !== 0 || (_3 = true), c2 = a3.scorer(e3, u3, a3)), c2 > a3.cutoff && (a3.returnObjects ? v3.push({ choice: t3, score: c2, key: r3 }) : v3.push([t3, c2, r3]));
44935
45378
  }), _3 && typeof console !== undefined && console.log("One or more choices were empty. (post-processing if applied)"), a3.limit && typeof a3.limit == "number" && a3.limit > 0 && a3.limit < o3 && !a3.unsorted ? v3 = t2.nlargest(v3, a3.limit, l3) : a3.unsorted || (v3 = v3.sort(h3)), v3;
44936
45379
  }
44937
45380
  function extractAsync(e3, r2, n3, o3) {
@@ -44967,7 +45410,7 @@ var Lo = { exports: {} };
44967
45410
  f3 || (s3.astral && s3.normalize && (s3.normalize = false, String.prototype.normalize ? (h3 = true, e3 = e3.normalize()) : typeof console !== undefined && console.warn("Normalization not supported in your environment")), e3 = pre_processor(e3, s3), s3.full_process = false, e3.length === 0 && typeof console !== undefined && console.warn("Processed query is empty string"));
44968
45411
  var v3, _3, d3, y3, $3, w3 = [], O3 = false, S3 = false, C2 = false;
44969
45412
  if (s3.scorer.name === "token_sort_ratio" || s3.scorer.name === "partial_token_sort_ratio") {
44970
- var z2 = g2(e3);
45413
+ var z2 = g3(e3);
44971
45414
  S3 = true;
44972
45415
  } else if (s3.scorer.name === "token_set_ratio" || s3.scorer.name === "partial_token_set_ratio") {
44973
45416
  var A2 = m2(e3, s3);
@@ -44986,7 +45429,7 @@ var Lo = { exports: {} };
44986
45429
  l3 ? searchLoop(0) : searchLoop(x2[0], 0);
44987
45430
  }
44988
45431
  function searchLoop(n4, p3) {
44989
- (l3 || r2.hasOwnProperty(n4)) && (s3.tokens = undefined, s3.proc_sorted = false, S3 ? (s3.proc_sorted = true, r2[n4] && r2[n4].proc_sorted ? _3 = r2[n4].proc_sorted : (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3), _3 = g2(_3)), d3 = s3.scorer(z2, _3, s3)) : C2 ? (_3 = "x", r2[n4] && r2[n4].tokens ? (s3.tokens = [A2, r2[n4].tokens], s3.trySimple && (_3 = pre_processor(s3.processor(r2[n4]), s3))) : (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3), s3.tokens = [A2, m2(_3, s3)]), d3 = s3.scorer(e3, _3, s3)) : f3 ? (_3 = s3.processor(r2[n4]), d3 = s3.scorer(e3, _3, s3)) : (typeof (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3)) == "string" && _3.length !== 0 || (O3 = true), d3 = s3.scorer(e3, _3, s3)), v3 = l3 ? parseInt(n4) : n4, d3 > s3.cutoff && (s3.returnObjects ? w3.push({ choice: r2[n4], score: d3, key: v3 }) : w3.push([r2[n4], d3, v3]))), a3 && a3.signal.aborted === true ? o3(new Error("aborted")) : i2 && i2.canceled === true ? o3(new Error("canceled")) : l3 && n4 < r2.length - 1 ? n4 % c2 == 0 ? setImmediate(function() {
45432
+ (l3 || r2.hasOwnProperty(n4)) && (s3.tokens = undefined, s3.proc_sorted = false, S3 ? (s3.proc_sorted = true, r2[n4] && r2[n4].proc_sorted ? _3 = r2[n4].proc_sorted : (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3), _3 = g3(_3)), d3 = s3.scorer(z2, _3, s3)) : C2 ? (_3 = "x", r2[n4] && r2[n4].tokens ? (s3.tokens = [A2, r2[n4].tokens], s3.trySimple && (_3 = pre_processor(s3.processor(r2[n4]), s3))) : (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3), s3.tokens = [A2, m2(_3, s3)]), d3 = s3.scorer(e3, _3, s3)) : f3 ? (_3 = s3.processor(r2[n4]), d3 = s3.scorer(e3, _3, s3)) : (typeof (_3 = pre_processor(h3 ? s3.processor(r2[n4]).normalize() : s3.processor(r2[n4]), s3)) == "string" && _3.length !== 0 || (O3 = true), d3 = s3.scorer(e3, _3, s3)), v3 = l3 ? parseInt(n4) : n4, d3 > s3.cutoff && (s3.returnObjects ? w3.push({ choice: r2[n4], score: d3, key: v3 }) : w3.push([r2[n4], d3, v3]))), a3 && a3.signal.aborted === true ? o3(new Error("aborted")) : i2 && i2.canceled === true ? o3(new Error("canceled")) : l3 && n4 < r2.length - 1 ? n4 % c2 == 0 ? setImmediate(function() {
44990
45433
  searchLoop(n4 + 1);
44991
45434
  }) : searchLoop(n4 + 1) : p3 < x2.length - 1 ? p3 % c2 == 0 ? setImmediate(function() {
44992
45435
  searchLoop(x2[p3 + 1], p3 + 1);
@@ -45072,11 +45515,11 @@ var Lo = { exports: {} };
45072
45515
  }, f3 = a2(n3, i2, wildCompare), p3 = u2(n3, i2, wildCompare), h3 = u2(i2, n3, wildCompare);
45073
45516
  else
45074
45517
  f3 = o2(n3, i2), p3 = s2(n3, i2), h3 = s2(i2, n3);
45075
- var d3 = f3.sort().join(" "), y3 = p3.sort(), g3 = h3.sort();
45518
+ var d3 = f3.sort().join(" "), y3 = p3.sort(), g4 = h3.sort();
45076
45519
  if (r2.sortBySimilarity)
45077
- var b3 = _order_token_lists(e3, y3, t3, g3), k3 = b3[0], j3 = b3[1], $3 = k3.join(" "), w3 = _token_similarity_sort(k3, j3, r2).join(" ");
45520
+ var b3 = _order_token_lists(e3, y3, t3, g4), k3 = b3[0], j3 = b3[1], $3 = k3.join(" "), w3 = _token_similarity_sort(k3, j3, r2).join(" ");
45078
45521
  else
45079
- $3 = y3.join(" "), w3 = g3.join(" ");
45522
+ $3 = y3.join(" "), w3 = g4.join(" ");
45080
45523
  var O3 = d3 + " " + $3, S3 = d3 + " " + w3;
45081
45524
  d3 = d3.trim(), O3 = O3.trim(), S3 = S3.trim();
45082
45525
  var C2 = _ratio;
@@ -45161,7 +45604,7 @@ var Lo = { exports: {} };
45161
45604
  }
45162
45605
  var p3 = token_sort_ratio(e3, t3, n3) * a3, h3 = token_set_ratio(e3, t3, n3) * a3;
45163
45606
  return Math.round(Math.max(s3, p3, h3));
45164
- }, full_process: b2, extract, extractAsync, extractAsPromised: O2, process_and_sort: g2, unique_tokens: m2, dedupe: $2 };
45607
+ }, full_process: b2, extract, extractAsync, extractAsPromised: O2, process_and_sort: g3, unique_tokens: m2, dedupe: $2 };
45165
45608
  r.exports = S2;
45166
45609
  }();
45167
45610
  var Ro = r.exports;
@@ -45185,9 +45628,35 @@ var ta = Ro.dedupe;
45185
45628
 
45186
45629
  // ../reconciliation/src/normalise.ts
45187
45630
  var CORPORATE_SUFFIXES = /\b(PRIVATE|PVT\.?|LIMITED|LTD\.?)\s*\w*/gi;
45631
+ var TOKEN_SET_THRESHOLD = 80;
45188
45632
  function normaliseName(name) {
45189
45633
  return name.replace(CORPORATE_SUFFIXES, "").replace(/[^\w\s]/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
45190
45634
  }
45635
+ function getMatchName(extractedName, description) {
45636
+ if (extractedName && extractedName.trim().length > 0) {
45637
+ return normaliseName(extractedName);
45638
+ }
45639
+ return normaliseName(description);
45640
+ }
45641
+ function isSameMerchant(a2, b2) {
45642
+ const normA = normaliseName(a2);
45643
+ const normB = normaliseName(b2);
45644
+ if (normA.length === 0 || normB.length === 0)
45645
+ return false;
45646
+ if (normA === normB)
45647
+ return true;
45648
+ if (aliasesMatch(normA, normB))
45649
+ return true;
45650
+ const shorter = Math.min(normA.length, normB.length);
45651
+ const longer = Math.max(normA.length, normB.length);
45652
+ if (shorter < 4 || shorter / longer < 0.4)
45653
+ return false;
45654
+ if (Fo(normA, normB) >= TOKEN_SET_THRESHOLD)
45655
+ return true;
45656
+ if (Bo(normA, normB) >= 90)
45657
+ return true;
45658
+ return false;
45659
+ }
45191
45660
 
45192
45661
  // ../reconciliation/src/entity.ts
45193
45662
  var FUZZBALL_THRESHOLD = 92;
@@ -45329,6 +45798,151 @@ function entityRule(e2, bag) {
45329
45798
  return bag.merchantRules[e2.merchantId] ?? null;
45330
45799
  return bag.personRules[e2.personId] ?? null;
45331
45800
  }
45801
+ // ../reconciliation/src/match.ts
45802
+ var DEFAULT_DATE_TOLERANCE = 2;
45803
+ var DEFAULT_AMOUNT_TOLERANCE = 0.02;
45804
+ function toDays(dateStr) {
45805
+ const [y2, m2, d2] = dateStr.split("-").map(Number);
45806
+ return Math.round(Date.UTC(y2, m2 - 1, d2) / 86400000);
45807
+ }
45808
+ function amountsClose(a2, b2, tolerance) {
45809
+ if (a2 === 0 || b2 === 0)
45810
+ return false;
45811
+ return Math.abs(a2 - b2) / Math.max(a2, b2) <= tolerance;
45812
+ }
45813
+ function namesSimilar(inc, ex) {
45814
+ if (inc.upiId && ex.upiId && inc.upiId === ex.upiId)
45815
+ return true;
45816
+ const incName = getMatchName(inc.extractedName, inc.description);
45817
+ const exName = getMatchName(ex.extractedName ?? ex.merchantName, ex.description);
45818
+ return isSameMerchant(incName, exName);
45819
+ }
45820
+ function matchTransactions(existing, incoming, config2) {
45821
+ const dateTolerance = config2?.dateTolerance ?? DEFAULT_DATE_TOLERANCE;
45822
+ const amountTolerance = config2?.amountTolerance ?? DEFAULT_AMOUNT_TOLERANCE;
45823
+ const claimedExisting = new Set;
45824
+ const claimedIncoming = new Set;
45825
+ const matched = [];
45826
+ const conflicts = [];
45827
+ const eligible = existing.filter((t2) => !t2.isDuplicate && !t2.isIgnored);
45828
+ for (let i2 = 0;i2 < incoming.length; i2++) {
45829
+ if (claimedIncoming.has(i2))
45830
+ continue;
45831
+ const inc = incoming[i2];
45832
+ for (const ex of eligible) {
45833
+ if (claimedExisting.has(ex.id))
45834
+ continue;
45835
+ if (inc.description === ex.description && inc.amount === ex.amount && inc.type === ex.type && inc.date === ex.date) {
45836
+ matched.push({
45837
+ existing: ex,
45838
+ incoming: inc,
45839
+ incomingIndex: i2,
45840
+ confidence: "exact",
45841
+ reason: "Same row: identical description, amount, type, and date"
45842
+ });
45843
+ claimedExisting.add(ex.id);
45844
+ claimedIncoming.add(i2);
45845
+ break;
45846
+ }
45847
+ }
45848
+ }
45849
+ for (let i2 = 0;i2 < incoming.length; i2++) {
45850
+ if (claimedIncoming.has(i2))
45851
+ continue;
45852
+ const inc = incoming[i2];
45853
+ if (!inc.referenceNumber)
45854
+ continue;
45855
+ for (const ex of eligible) {
45856
+ if (claimedExisting.has(ex.id))
45857
+ continue;
45858
+ if (ex.referenceNumber === inc.referenceNumber && inc.type === ex.type && inc.amount === ex.amount) {
45859
+ matched.push({
45860
+ existing: ex,
45861
+ incoming: inc,
45862
+ incomingIndex: i2,
45863
+ confidence: "exact",
45864
+ reason: `Reference number match: ${inc.referenceNumber}`
45865
+ });
45866
+ claimedExisting.add(ex.id);
45867
+ claimedIncoming.add(i2);
45868
+ break;
45869
+ }
45870
+ }
45871
+ }
45872
+ for (let i2 = 0;i2 < incoming.length; i2++) {
45873
+ if (claimedIncoming.has(i2))
45874
+ continue;
45875
+ const inc = incoming[i2];
45876
+ for (const ex of eligible) {
45877
+ if (claimedExisting.has(ex.id))
45878
+ continue;
45879
+ if (inc.amount === ex.amount && inc.type === ex.type && inc.date === ex.date && namesSimilar(inc, ex)) {
45880
+ matched.push({
45881
+ existing: ex,
45882
+ incoming: inc,
45883
+ incomingIndex: i2,
45884
+ confidence: "exact",
45885
+ reason: `Exact match: ${inc.amount} on ${inc.date}`
45886
+ });
45887
+ claimedExisting.add(ex.id);
45888
+ claimedIncoming.add(i2);
45889
+ break;
45890
+ }
45891
+ }
45892
+ }
45893
+ for (let i2 = 0;i2 < incoming.length; i2++) {
45894
+ if (claimedIncoming.has(i2))
45895
+ continue;
45896
+ const inc = incoming[i2];
45897
+ const incDays = toDays(inc.date);
45898
+ for (const ex of eligible) {
45899
+ if (claimedExisting.has(ex.id))
45900
+ continue;
45901
+ const daysDiff = Math.abs(incDays - toDays(ex.date));
45902
+ if (daysDiff > 0 && daysDiff <= dateTolerance && inc.amount === ex.amount && inc.type === ex.type && namesSimilar(inc, ex)) {
45903
+ matched.push({
45904
+ existing: ex,
45905
+ incoming: inc,
45906
+ incomingIndex: i2,
45907
+ confidence: "date_tolerance",
45908
+ reason: `Amount + name match, ${daysDiff} day${daysDiff > 1 ? "s" : ""} apart`
45909
+ });
45910
+ claimedExisting.add(ex.id);
45911
+ claimedIncoming.add(i2);
45912
+ break;
45913
+ }
45914
+ }
45915
+ }
45916
+ for (let i2 = 0;i2 < incoming.length; i2++) {
45917
+ if (claimedIncoming.has(i2))
45918
+ continue;
45919
+ const inc = incoming[i2];
45920
+ const incDays = toDays(inc.date);
45921
+ for (const ex of eligible) {
45922
+ if (claimedExisting.has(ex.id))
45923
+ continue;
45924
+ const daysDiff = Math.abs(incDays - toDays(ex.date));
45925
+ if (daysDiff <= dateTolerance && inc.type === ex.type && inc.amount !== ex.amount && amountsClose(inc.amount, ex.amount, amountTolerance) && namesSimilar(inc, ex)) {
45926
+ const pctDiff = (Math.abs(inc.amount - ex.amount) / Math.max(inc.amount, ex.amount) * 100).toFixed(1);
45927
+ conflicts.push({
45928
+ existing: ex,
45929
+ incoming: inc,
45930
+ incomingIndex: i2,
45931
+ reason: `Amount differs by ${pctDiff}% (${ex.amount} vs ${inc.amount})`
45932
+ });
45933
+ claimedExisting.add(ex.id);
45934
+ claimedIncoming.add(i2);
45935
+ break;
45936
+ }
45937
+ }
45938
+ }
45939
+ const unmatchedIndices = [];
45940
+ for (let i2 = 0;i2 < incoming.length; i2++) {
45941
+ if (!claimedIncoming.has(i2))
45942
+ unmatchedIndices.push(i2);
45943
+ }
45944
+ return { matched, conflicts, unmatchedIndices };
45945
+ }
45332
45946
  // ../reconciliation/src/settlement.ts
45333
45947
  var SETTLEMENT_PATTERNS = [
45334
45948
  /\bCRED\b.*\bPAYMENT\b/i,
@@ -45405,20 +46019,344 @@ function settlementEnrichedData(description, type, source) {
45405
46019
  out.settlementCardLast4 = last4;
45406
46020
  return out;
45407
46021
  }
45408
- // ../parsers/src/classify.ts
45409
- var CORPORATE_TOKENS = [
45410
- "PVT",
45411
- "PRIVATE",
45412
- "LIMITED",
45413
- "LTD",
45414
- "LLP",
45415
- "INC",
45416
- "CORP",
45417
- "ENTERPRISES",
45418
- "TECHNOLOGIES",
45419
- "SOLUTIONS",
45420
- "SERVICES"
45421
- ];
46022
+ function detectSettlements(transactions, accountLinks) {
46023
+ const settlements = [];
46024
+ const debits = transactions.filter((t2) => t2.type === "debit" && !t2.isDuplicate && !t2.isIgnored && !t2.isTransfer);
46025
+ for (const debit of debits) {
46026
+ const patternMatch = isSettlementDescription(debit.description);
46027
+ if (!patternMatch)
46028
+ continue;
46029
+ const linkedAccount = findLinkedCreditCard(debit, accountLinks);
46030
+ const { brand, last4 } = extractSettlementCardInfo(debit.description);
46031
+ settlements.push({
46032
+ bankDebit: debit,
46033
+ creditAccountId: linkedAccount?.creditAccountId ?? "unknown",
46034
+ statementTotal: null,
46035
+ reason: linkedAccount ? `Settlement for ${linkedAccount.creditAccountId} (keyword: ${linkedAccount.settlementKeyword})` : `Likely CC settlement (pattern: ${patternMatch})`,
46036
+ cardBrand: brand,
46037
+ cardLast4: last4
46038
+ });
46039
+ }
46040
+ return settlements;
46041
+ }
46042
+ function findLinkedCreditCard(debit, accountLinks) {
46043
+ if (!debit.accountId)
46044
+ return null;
46045
+ for (const link of accountLinks) {
46046
+ if (link.debitAccountId !== debit.accountId)
46047
+ continue;
46048
+ if (link.settlementKeyword) {
46049
+ const normDesc = normaliseName(debit.description);
46050
+ if (normDesc.includes(normaliseName(link.settlementKeyword))) {
46051
+ return link;
46052
+ }
46053
+ } else {
46054
+ return link;
46055
+ }
46056
+ }
46057
+ return null;
46058
+ }
46059
+
46060
+ // ../reconciliation/src/reconcile.ts
46061
+ function reconcile(existing, incoming, config2) {
46062
+ const accountLinks = config2?.accountLinks ?? [];
46063
+ const settlements = detectSettlements(existing, accountLinks);
46064
+ const { matched, conflicts, unmatchedIndices } = matchTransactions(existing, incoming.transactions, {
46065
+ dateTolerance: config2?.dateTolerance,
46066
+ amountTolerance: config2?.amountTolerance
46067
+ });
46068
+ const unmatched = unmatchedIndices.map((i2) => incoming.transactions[i2]);
46069
+ return {
46070
+ matched,
46071
+ settlements,
46072
+ conflicts,
46073
+ unmatched,
46074
+ unmatchedIndices
46075
+ };
46076
+ }
46077
+ // src/lib/link-batches.ts
46078
+ var MAX_LINKS = 500;
46079
+ var MAX_TARGETS = 25;
46080
+ function chunkLinks(links) {
46081
+ const batches = [];
46082
+ let cur = [];
46083
+ let targets = new Set;
46084
+ for (const l2 of links) {
46085
+ const key = `${l2.merchantId ?? ""}|${l2.personId ?? ""}|${l2.categoryId ?? ""}`;
46086
+ if (cur.length >= MAX_LINKS || !targets.has(key) && targets.size >= MAX_TARGETS) {
46087
+ batches.push(cur);
46088
+ cur = [];
46089
+ targets = new Set;
46090
+ }
46091
+ cur.push(l2);
46092
+ targets.add(key);
46093
+ }
46094
+ if (cur.length)
46095
+ batches.push(cur);
46096
+ return batches;
46097
+ }
46098
+ async function postLinks(client, links) {
46099
+ for (const batch of chunkLinks(links)) {
46100
+ await client.post("/api/transactions/bulk-link-entity", { links: batch });
46101
+ }
46102
+ }
46103
+
46104
+ // src/lib/map-limit.ts
46105
+ async function mapLimit(items, limit, fn2) {
46106
+ const out = new Array(items.length);
46107
+ let next = 0;
46108
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
46109
+ while (next < items.length) {
46110
+ const i2 = next++;
46111
+ out[i2] = await fn2(items[i2]);
46112
+ }
46113
+ }));
46114
+ return out;
46115
+ }
46116
+ // ../parsers/src/amex/credit-card-pdf.ts
46117
+ var _pdfjs = null;
46118
+ async function getPdfjs() {
46119
+ if (!_pdfjs) {
46120
+ _pdfjs = await import("pdfjs-dist/legacy/build/pdf.mjs");
46121
+ _pdfjs.GlobalWorkerOptions.workerSrc = import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs");
46122
+ }
46123
+ return _pdfjs;
46124
+ }
46125
+ var X_DATE_MAX = 20;
46126
+ var X_DESC_MIN = 80;
46127
+ var X_DESC_MAX = 340;
46128
+ var X_AMOUNT_MIN = 465;
46129
+ var Y_DATA_MIN = 260;
46130
+ var ROW_TOLERANCE = 12;
46131
+ async function parseAmexCreditCardPdf(data, password) {
46132
+ const pdfjs = await getPdfjs();
46133
+ const loadOptions = {
46134
+ data: new Uint8Array(data),
46135
+ useWorkerFetch: false,
46136
+ isEvalSupported: false,
46137
+ useSystemFonts: true
46138
+ };
46139
+ if (password)
46140
+ loadOptions.password = password;
46141
+ const pdf = await pdfjs.getDocument(loadOptions).promise;
46142
+ const items = await extractTextItems(pdf);
46143
+ const metadata = extractMetadata(items);
46144
+ const transactions = parseTransactions(items, metadata);
46145
+ return {
46146
+ bank: "amex",
46147
+ accountType: "credit_card",
46148
+ accountNumber: metadata.accountLast4,
46149
+ periodStart: metadata.periodStart,
46150
+ periodEnd: metadata.periodEnd,
46151
+ transactions
46152
+ };
46153
+ }
46154
+ async function extractTextItems(pdf) {
46155
+ const result = [];
46156
+ for (let p2 = 1;p2 <= pdf.numPages; p2++) {
46157
+ const page = await pdf.getPage(p2);
46158
+ const content = await page.getTextContent();
46159
+ for (const item of content.items) {
46160
+ const ti = item;
46161
+ if (typeof ti.str !== "string" || !ti.str.trim())
46162
+ continue;
46163
+ result.push({
46164
+ page: p2,
46165
+ x: Math.round(ti.transform[4]),
46166
+ y: Math.round(ti.transform[5]),
46167
+ str: ti.str
46168
+ });
46169
+ }
46170
+ }
46171
+ return result;
46172
+ }
46173
+ function extractMetadata(items) {
46174
+ const page1Items = items.filter((i2) => i2.page === 1);
46175
+ const rowStrings = groupIntoRowStrings(page1Items);
46176
+ const fullText = rowStrings.join(" ");
46177
+ let accountLast4 = null;
46178
+ let periodStart = null;
46179
+ let periodEnd = null;
46180
+ let periodEndYear = new Date().getFullYear();
46181
+ let periodEndMonth = 1;
46182
+ const accountMatch = fullText.match(/XXXX-XXXXXX-(\d+)/);
46183
+ if (accountMatch) {
46184
+ accountLast4 = accountMatch[1].slice(-4);
46185
+ }
46186
+ const periodMatch = fullText.match(/From\s+(\w+\s+\d+)\s+to\s+(\w+\s+\d+,?\s*\d{4})/i);
46187
+ if (periodMatch) {
46188
+ const endParsed = parseMonthDayYear(periodMatch[2]);
46189
+ if (endParsed) {
46190
+ periodEnd = endParsed.iso;
46191
+ periodEndYear = endParsed.year;
46192
+ periodEndMonth = endParsed.month;
46193
+ const startParsed = parseMonthDay(periodMatch[1]);
46194
+ if (startParsed) {
46195
+ const startYear = startParsed.month > periodEndMonth ? periodEndYear - 1 : periodEndYear;
46196
+ periodStart = `${startYear}-${String(startParsed.month).padStart(2, "0")}-${String(startParsed.day).padStart(2, "0")}`;
46197
+ }
46198
+ }
46199
+ }
46200
+ return { accountLast4, periodStart, periodEnd, periodEndYear, periodEndMonth };
46201
+ }
46202
+ function groupIntoRowStrings(items) {
46203
+ const buckets = new Map;
46204
+ for (const item of items) {
46205
+ const pagePrefix = `${item.page}:`;
46206
+ let bucketKey = null;
46207
+ for (const key of buckets.keys()) {
46208
+ if (!key.startsWith(pagePrefix))
46209
+ continue;
46210
+ if (Math.abs(item.y - parseInt(key.slice(pagePrefix.length), 10)) <= ROW_TOLERANCE) {
46211
+ bucketKey = key;
46212
+ break;
46213
+ }
46214
+ }
46215
+ if (bucketKey === null) {
46216
+ bucketKey = `${item.page}:${item.y}`;
46217
+ buckets.set(bucketKey, [item]);
46218
+ } else {
46219
+ const existing = buckets.get(bucketKey);
46220
+ if (existing)
46221
+ existing.push(item);
46222
+ }
46223
+ }
46224
+ return [...buckets.values()].map((row) => row.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" "));
46225
+ }
46226
+ function parseTransactions(items, metadata) {
46227
+ const dataItems = items.filter((i2) => i2.y > Y_DATA_MIN);
46228
+ const rows = buildRows(dataItems);
46229
+ const transactions = [];
46230
+ for (const row of rows) {
46231
+ const dateCell = row.find((c2) => c2.x <= X_DATE_MAX);
46232
+ if (!dateCell)
46233
+ continue;
46234
+ const date4 = parseAmexPdfDate(dateCell.str.trim(), metadata);
46235
+ if (!date4)
46236
+ continue;
46237
+ const descCells = row.filter((c2) => c2.x >= X_DESC_MIN && c2.x < X_DESC_MAX);
46238
+ if (descCells.length === 0)
46239
+ continue;
46240
+ const description = descCells.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" ").replace(/\s{2,}/g, " ").trim();
46241
+ if (!description)
46242
+ continue;
46243
+ const amountCells = row.filter((c2) => c2.x >= X_AMOUNT_MIN);
46244
+ const isCredit = amountCells.some((c2) => c2.str.trim() === "CR");
46245
+ const amountCell = amountCells.find((c2) => c2.str.trim() !== "CR");
46246
+ if (!amountCell)
46247
+ continue;
46248
+ const amount = parseAmount(amountCell.str);
46249
+ if (amount === 0)
46250
+ continue;
46251
+ const type = isCredit ? "credit" : "debit";
46252
+ transactions.push({
46253
+ date: date4,
46254
+ description,
46255
+ amount,
46256
+ type,
46257
+ referenceNumber: null,
46258
+ valueDate: null,
46259
+ closingBalance: null,
46260
+ paymentMethod: null,
46261
+ extractedName: description,
46262
+ upiId: null,
46263
+ sourceCategory: null
46264
+ });
46265
+ }
46266
+ return transactions;
46267
+ }
46268
+ function buildRows(items) {
46269
+ const buckets = new Map;
46270
+ for (const item of items) {
46271
+ const pagePrefix = `${item.page}:`;
46272
+ let bucketKey = null;
46273
+ for (const key of buckets.keys()) {
46274
+ if (!key.startsWith(pagePrefix))
46275
+ continue;
46276
+ const bucketY = parseInt(key.slice(pagePrefix.length), 10);
46277
+ if (Math.abs(item.y - bucketY) <= ROW_TOLERANCE) {
46278
+ bucketKey = key;
46279
+ break;
46280
+ }
46281
+ }
46282
+ if (bucketKey === null) {
46283
+ bucketKey = `${item.page}:${item.y}`;
46284
+ buckets.set(bucketKey, [item]);
46285
+ } else {
46286
+ const existing = buckets.get(bucketKey);
46287
+ if (existing)
46288
+ existing.push(item);
46289
+ }
46290
+ }
46291
+ return [...buckets.entries()].sort((a2, b2) => {
46292
+ const [pa, ya] = a2[0].split(":").map(Number);
46293
+ const [pb, yb] = b2[0].split(":").map(Number);
46294
+ return pa !== pb ? pa - pb : yb - ya;
46295
+ }).map(([, row]) => row.sort((r1, r2) => r1.x - r2.x));
46296
+ }
46297
+ var MONTH_MAP = {
46298
+ january: 1,
46299
+ february: 2,
46300
+ march: 3,
46301
+ april: 4,
46302
+ may: 5,
46303
+ june: 6,
46304
+ july: 7,
46305
+ august: 8,
46306
+ september: 9,
46307
+ october: 10,
46308
+ november: 11,
46309
+ december: 12
46310
+ };
46311
+ function parseMonthDayYear(raw) {
46312
+ const match = raw.trim().match(/^(\w+)\s+(\d{1,2}),?\s+(\d{4})$/);
46313
+ if (!match)
46314
+ return null;
46315
+ const month = MONTH_MAP[match[1].toLowerCase()];
46316
+ if (!month)
46317
+ return null;
46318
+ const day = parseInt(match[2], 10);
46319
+ const year = parseInt(match[3], 10);
46320
+ const iso = `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
46321
+ return { month, day, year, iso };
46322
+ }
46323
+ function parseMonthDay(raw) {
46324
+ const match = raw.trim().match(/^(\w+)\s+(\d{1,2})$/);
46325
+ if (!match)
46326
+ return null;
46327
+ const month = MONTH_MAP[match[1].toLowerCase()];
46328
+ if (!month)
46329
+ return null;
46330
+ return { month, day: parseInt(match[2], 10) };
46331
+ }
46332
+ function parseAmexPdfDate(raw, meta2) {
46333
+ const parsed = parseMonthDay(raw);
46334
+ if (!parsed)
46335
+ return null;
46336
+ const year = parsed.month > meta2.periodEndMonth ? meta2.periodEndYear - 1 : meta2.periodEndYear;
46337
+ return `${year}-${String(parsed.month).padStart(2, "0")}-${String(parsed.day).padStart(2, "0")}`;
46338
+ }
46339
+ function parseAmount(raw) {
46340
+ if (!raw)
46341
+ return 0;
46342
+ const cleaned = raw.replace(/,/g, "").trim();
46343
+ const num = parseFloat(cleaned);
46344
+ return Number.isNaN(num) ? 0 : Math.abs(num);
46345
+ }
46346
+ // ../parsers/src/classify.ts
46347
+ var CORPORATE_TOKENS = [
46348
+ "PVT",
46349
+ "PRIVATE",
46350
+ "LIMITED",
46351
+ "LTD",
46352
+ "LLP",
46353
+ "INC",
46354
+ "CORP",
46355
+ "ENTERPRISES",
46356
+ "TECHNOLOGIES",
46357
+ "SOLUTIONS",
46358
+ "SERVICES"
46359
+ ];
45422
46360
  var CORPORATE_REGEX = new RegExp(`\\b(${CORPORATE_TOKENS.join("|")})\\b`);
45423
46361
  function classifyEntity({ paymentMethod, extractedName }) {
45424
46362
  if (paymentMethod === "atm")
@@ -45438,6 +46376,17 @@ function classifyEntity({ paymentMethod, extractedName }) {
45438
46376
  return "merchant";
45439
46377
  return "unknown";
45440
46378
  }
46379
+ // ../parsers/src/lib/parse-helpers.ts
46380
+ function parseAmount2(cell) {
46381
+ if (cell === null || cell === undefined || cell === "")
46382
+ return 0;
46383
+ if (typeof cell === "number")
46384
+ return Math.abs(cell);
46385
+ const cleaned = String(cell).replace(/,/g, "").trim();
46386
+ const num = parseFloat(cleaned);
46387
+ return Number.isNaN(num) ? 0 : Math.abs(num);
46388
+ }
46389
+
45441
46390
  // ../parsers/src/hdfc/narration.ts
45442
46391
  function parseHdfcNarration(narration) {
45443
46392
  const trimmed = narration.trim();
@@ -45626,6 +46575,796 @@ function parseSbiNarration(narration) {
45626
46575
  }
45627
46576
  return { paymentMethod: null, name: null, upiId: null };
45628
46577
  }
46578
+ // ../parsers/src/hdfc/cc-pdf.ts
46579
+ var _pdfjs2 = null;
46580
+ async function getPdfjs2() {
46581
+ if (!_pdfjs2) {
46582
+ _pdfjs2 = await import("pdfjs-dist/legacy/build/pdf.mjs");
46583
+ _pdfjs2.GlobalWorkerOptions.workerSrc = import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs");
46584
+ }
46585
+ return _pdfjs2;
46586
+ }
46587
+ var NOISE = /^(C|l|EMI|PI)$/;
46588
+ var NEUCOIN = /^[+-]\s*[\d,]/;
46589
+ var DATE_CELL = /^\d{2}\/\d{2}\/\d{4}/;
46590
+ var AMOUNT_CELL = /^[\d,]+\.\d{2}$/;
46591
+ var CREDIT_CELL = /^\+$/;
46592
+ var ROW_TOLERANCE2 = 4;
46593
+ async function parseHdfcCreditCardPdf(data, password) {
46594
+ const pdfjs = await getPdfjs2();
46595
+ const loadOptions = {
46596
+ data: new Uint8Array(data),
46597
+ useWorkerFetch: false,
46598
+ isEvalSupported: false,
46599
+ useSystemFonts: true
46600
+ };
46601
+ if (password)
46602
+ loadOptions.password = password;
46603
+ const pdf = await pdfjs.getDocument(loadOptions).promise;
46604
+ const items = await extractTextItems2(pdf);
46605
+ const metadata = extractMetadata2(items);
46606
+ const transactions = parseTransactions2(items);
46607
+ return {
46608
+ bank: "hdfc",
46609
+ accountType: "credit_card",
46610
+ accountNumber: metadata.accountLast4,
46611
+ periodStart: metadata.periodStart,
46612
+ periodEnd: metadata.periodEnd,
46613
+ transactions
46614
+ };
46615
+ }
46616
+ async function extractTextItems2(pdf) {
46617
+ const result = [];
46618
+ for (let p2 = 1;p2 <= pdf.numPages; p2++) {
46619
+ const page = await pdf.getPage(p2);
46620
+ const content = await page.getTextContent();
46621
+ for (const item of content.items) {
46622
+ const ti = item;
46623
+ if (typeof ti.str !== "string" || !ti.str.trim())
46624
+ continue;
46625
+ result.push({
46626
+ page: p2,
46627
+ x: Math.round(ti.transform[4]),
46628
+ y: Math.round(ti.transform[5]),
46629
+ str: ti.str
46630
+ });
46631
+ }
46632
+ }
46633
+ return result;
46634
+ }
46635
+ function extractMetadata2(items) {
46636
+ const fullText = items.map((i2) => i2.str).join(" ");
46637
+ const cardMatch = fullText.match(/(\d{6}X{6}\d{4})/);
46638
+ const accountLast4 = cardMatch ? cardMatch[1].slice(-4) : null;
46639
+ const DATE_PART = /\d{1,2}\s+\w+,?\s+\d{4}/;
46640
+ const periodMatch = fullText.match(new RegExp(`(${DATE_PART.source})\\s*[-–]\\s*(${DATE_PART.source})`));
46641
+ let periodStart = null;
46642
+ let periodEnd = null;
46643
+ if (periodMatch) {
46644
+ periodStart = parseMonthNameDate(periodMatch[1]);
46645
+ periodEnd = parseMonthNameDate(periodMatch[2]);
46646
+ }
46647
+ return { accountLast4, periodStart, periodEnd };
46648
+ }
46649
+ function parseTransactions2(items) {
46650
+ const rows = buildRows2(items);
46651
+ const transactions = [];
46652
+ for (const row of rows) {
46653
+ const dateItem = row.find((c2) => DATE_CELL.test(c2.str.trim()));
46654
+ if (!dateItem)
46655
+ continue;
46656
+ const date4 = parseDdMmYyyy(dateItem.str);
46657
+ if (!date4)
46658
+ continue;
46659
+ const useful = row.filter((c2) => !NOISE.test(c2.str.trim()) && !NEUCOIN.test(c2.str.trim()));
46660
+ const amountItem = [...useful].reverse().find((c2) => AMOUNT_CELL.test(c2.str.trim()));
46661
+ if (!amountItem)
46662
+ continue;
46663
+ const amount = parseAmount2(amountItem.str);
46664
+ if (amount === 0)
46665
+ continue;
46666
+ const isCredit = useful.some((c2) => CREDIT_CELL.test(c2.str.trim()));
46667
+ const description = useful.filter((c2) => c2 !== dateItem && c2 !== amountItem && !CREDIT_CELL.test(c2.str.trim()) && c2.x < amountItem.x).sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" ").replace(/([a-z])([A-Z][a-z])/g, "$1 $2").replace(/\s{2,}/g, " ").trim();
46668
+ if (!description)
46669
+ continue;
46670
+ transactions.push({
46671
+ date: date4,
46672
+ description,
46673
+ amount,
46674
+ type: isCredit ? "credit" : "debit",
46675
+ referenceNumber: null,
46676
+ valueDate: null,
46677
+ closingBalance: null,
46678
+ paymentMethod: null,
46679
+ extractedName: description,
46680
+ upiId: null,
46681
+ sourceCategory: null
46682
+ });
46683
+ }
46684
+ return transactions;
46685
+ }
46686
+ function buildRows2(items) {
46687
+ const buckets = new Map;
46688
+ for (const item of items) {
46689
+ const pagePrefix = `${item.page}:`;
46690
+ let bucketKey = null;
46691
+ for (const key of buckets.keys()) {
46692
+ if (!key.startsWith(pagePrefix))
46693
+ continue;
46694
+ const bucketY = parseInt(key.slice(pagePrefix.length), 10);
46695
+ if (Math.abs(item.y - bucketY) <= ROW_TOLERANCE2) {
46696
+ bucketKey = key;
46697
+ break;
46698
+ }
46699
+ }
46700
+ if (bucketKey === null) {
46701
+ bucketKey = `${item.page}:${item.y}`;
46702
+ buckets.set(bucketKey, [item]);
46703
+ } else {
46704
+ const existing = buckets.get(bucketKey);
46705
+ if (existing)
46706
+ existing.push(item);
46707
+ }
46708
+ }
46709
+ return [...buckets.entries()].sort((a2, b2) => {
46710
+ const [pa, ya] = a2[0].split(":").map(Number);
46711
+ const [pb, yb] = b2[0].split(":").map(Number);
46712
+ return pa !== pb ? pa - pb : yb - ya;
46713
+ }).map(([, row]) => row.sort((a2, b2) => a2.x - b2.x));
46714
+ }
46715
+ var MONTH_NAMES = {
46716
+ jan: "01",
46717
+ feb: "02",
46718
+ mar: "03",
46719
+ apr: "04",
46720
+ may: "05",
46721
+ jun: "06",
46722
+ jul: "07",
46723
+ aug: "08",
46724
+ sep: "09",
46725
+ oct: "10",
46726
+ nov: "11",
46727
+ dec: "12"
46728
+ };
46729
+ function parseMonthNameDate(raw) {
46730
+ const match = raw.trim().match(/^(\d{1,2})\s+(\w{3})\w*,?\s+(\d{4})$/);
46731
+ if (!match)
46732
+ return null;
46733
+ const month = MONTH_NAMES[match[2].toLowerCase().slice(0, 3)];
46734
+ if (!month)
46735
+ return null;
46736
+ return `${match[3]}-${month}-${match[1].padStart(2, "0")}`;
46737
+ }
46738
+ function parseDdMmYyyy(raw) {
46739
+ const match = raw.trim().match(/^(\d{2})\/(\d{2})\/(\d{4})/);
46740
+ if (!match)
46741
+ return null;
46742
+ return `${match[3]}-${match[2]}-${match[1]}`;
46743
+ }
46744
+ // ../parsers/src/hdfc/savings-pdf.ts
46745
+ var _pdfjs3 = null;
46746
+ async function getPdfjs3() {
46747
+ if (!_pdfjs3) {
46748
+ _pdfjs3 = await import("pdfjs-dist/legacy/build/pdf.mjs");
46749
+ _pdfjs3.GlobalWorkerOptions.workerSrc = import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs");
46750
+ }
46751
+ return _pdfjs3;
46752
+ }
46753
+ var X_DATE_MAX2 = 56;
46754
+ var X_NARRATION_MIN = 60;
46755
+ var X_NARRATION_MAX = 280;
46756
+ var X_REF_MIN = 280;
46757
+ var X_VALUEDT_MIN = 355;
46758
+ var X_WITHDRAWAL_MIN = 402;
46759
+ var X_WITHDRAWAL_MAX = 490;
46760
+ var X_DEPOSIT_MIN = 490;
46761
+ var X_DEPOSIT_MAX = 560;
46762
+ var X_BALANCE_MIN = 560;
46763
+ var Y_DATA_MAX = 618;
46764
+ var Y_DATA_MIN2 = 55;
46765
+ var ROW_TOLERANCE3 = 4;
46766
+ async function parseHdfcSavingsPdf(data, password) {
46767
+ const pdfjs = await getPdfjs3();
46768
+ const loadOptions = {
46769
+ data: new Uint8Array(data),
46770
+ useWorkerFetch: false,
46771
+ isEvalSupported: false,
46772
+ useSystemFonts: true
46773
+ };
46774
+ if (password)
46775
+ loadOptions.password = password;
46776
+ const pdf = await pdfjs.getDocument(loadOptions).promise;
46777
+ const items = await extractTextItems3(pdf);
46778
+ const metadata = extractMetadata3(items);
46779
+ const transactions = parseHdfcSavingsItems(items);
46780
+ return {
46781
+ bank: "hdfc",
46782
+ accountType: metadata.accountType,
46783
+ accountNumber: metadata.accountLast4,
46784
+ periodStart: metadata.periodStart,
46785
+ periodEnd: metadata.periodEnd,
46786
+ transactions
46787
+ };
46788
+ }
46789
+ async function extractTextItems3(pdf) {
46790
+ const result = [];
46791
+ for (let p2 = 1;p2 <= pdf.numPages; p2++) {
46792
+ const page = await pdf.getPage(p2);
46793
+ const content = await page.getTextContent();
46794
+ for (const item of content.items) {
46795
+ const ti = item;
46796
+ if (typeof ti.str !== "string" || !ti.str.trim())
46797
+ continue;
46798
+ result.push({
46799
+ page: p2,
46800
+ x: Math.round(ti.transform[4]),
46801
+ y: Math.round(ti.transform[5]),
46802
+ str: ti.str,
46803
+ w: Math.round(ti.width ?? 0)
46804
+ });
46805
+ }
46806
+ }
46807
+ return result;
46808
+ }
46809
+ function extractMetadata3(items) {
46810
+ const headerItems = items.filter((i2) => i2.y > Y_DATA_MAX);
46811
+ const rowStrings = groupIntoRowStrings2(headerItems);
46812
+ const fullText = rowStrings.join(" ");
46813
+ let accountType = "savings";
46814
+ let accountLast4 = null;
46815
+ let periodStart = null;
46816
+ let periodEnd = null;
46817
+ const accountMatch = fullText.match(/Account No\s*:?\s*(\d{10,18})/);
46818
+ if (accountMatch) {
46819
+ accountLast4 = accountMatch[1].slice(-4);
46820
+ }
46821
+ if (/current/i.test(fullText) && /account.*type/i.test(fullText)) {
46822
+ accountType = "current";
46823
+ }
46824
+ const periodMatch = fullText.match(/(?:Statement\s+)?From\s*:\s*(\d{2}\/\d{2}\/\d{4})\s+To\s*:\s*(\d{2}\/\d{2}\/\d{4})/i);
46825
+ if (periodMatch) {
46826
+ periodStart = parseHdfcDate(periodMatch[1]);
46827
+ periodEnd = parseHdfcDate(periodMatch[2]);
46828
+ }
46829
+ return { accountType, accountLast4, periodStart, periodEnd };
46830
+ }
46831
+ function groupIntoRowStrings2(items) {
46832
+ const buckets = new Map;
46833
+ for (const item of items) {
46834
+ const pagePrefix = `${item.page}:`;
46835
+ let bucketKey = null;
46836
+ for (const key of buckets.keys()) {
46837
+ if (!key.startsWith(pagePrefix))
46838
+ continue;
46839
+ if (Math.abs(item.y - parseInt(key.slice(pagePrefix.length), 10)) <= ROW_TOLERANCE3) {
46840
+ bucketKey = key;
46841
+ break;
46842
+ }
46843
+ }
46844
+ if (bucketKey === null) {
46845
+ bucketKey = `${item.page}:${item.y}`;
46846
+ buckets.set(bucketKey, [item]);
46847
+ } else {
46848
+ const existing = buckets.get(bucketKey);
46849
+ if (existing)
46850
+ existing.push(item);
46851
+ }
46852
+ }
46853
+ return [...buckets.values()].map((row) => row.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" "));
46854
+ }
46855
+ var COMPACT_DATE_MAX_X = 90;
46856
+ var COMPACT_NARRATION_MIN_X = 90;
46857
+ var COMPACT_NUMERIC_MIN_X = 300;
46858
+ var COMPACT_WITHDRAWAL_MAX_RIGHT = 400;
46859
+ var COMPACT_DEPOSIT_MAX_RIGHT = 500;
46860
+ var AMOUNT_RE = /^[\d,]+\.\d{2}$/;
46861
+ function detectLayout(items) {
46862
+ const hdr = items.find((i2) => /^Narration$/i.test(i2.str.trim()));
46863
+ if (!hdr)
46864
+ return "classic";
46865
+ const headerRow = items.filter((i2) => i2.page === hdr.page && Math.abs(i2.y - hdr.y) <= ROW_TOLERANCE3);
46866
+ const isClassic = headerRow.some((i2) => /chq|ref\.?\s*no|value\s*dt/i.test(i2.str));
46867
+ return isClassic ? "classic" : "compact";
46868
+ }
46869
+ function classifyRow(row, layout) {
46870
+ if (layout === "compact") {
46871
+ const amounts = row.filter((c2) => c2.x >= COMPACT_NUMERIC_MIN_X && AMOUNT_RE.test(c2.str.trim()));
46872
+ const right = (c2) => c2.x + c2.w;
46873
+ return {
46874
+ dateCell: row.find((c2) => c2.x <= COMPACT_DATE_MAX_X),
46875
+ narrationCells: row.filter((c2) => c2.x > COMPACT_NARRATION_MIN_X && c2.x < COMPACT_NUMERIC_MIN_X),
46876
+ withdrawalCell: amounts.find((c2) => right(c2) < COMPACT_WITHDRAWAL_MAX_RIGHT),
46877
+ depositCell: amounts.find((c2) => right(c2) >= COMPACT_WITHDRAWAL_MAX_RIGHT && right(c2) < COMPACT_DEPOSIT_MAX_RIGHT),
46878
+ balanceCell: amounts.find((c2) => right(c2) >= COMPACT_DEPOSIT_MAX_RIGHT)
46879
+ };
46880
+ }
46881
+ return {
46882
+ dateCell: row.find((c2) => c2.x <= X_DATE_MAX2),
46883
+ narrationCells: row.filter((c2) => c2.x >= X_NARRATION_MIN && c2.x < X_NARRATION_MAX),
46884
+ refCell: row.find((c2) => c2.x >= X_REF_MIN && c2.x < X_VALUEDT_MIN),
46885
+ valueDtCell: row.find((c2) => c2.x >= X_VALUEDT_MIN && c2.x < X_WITHDRAWAL_MIN),
46886
+ withdrawalCell: row.find((c2) => c2.x >= X_WITHDRAWAL_MIN && c2.x < X_WITHDRAWAL_MAX),
46887
+ depositCell: row.find((c2) => c2.x >= X_DEPOSIT_MIN && c2.x < X_DEPOSIT_MAX),
46888
+ balanceCell: row.find((c2) => c2.x >= X_BALANCE_MIN)
46889
+ };
46890
+ }
46891
+ function parseHdfcSavingsItems(items) {
46892
+ const transactions = parseTransactions3(items, detectLayout(items));
46893
+ assertBalanceContinuity(transactions);
46894
+ return transactions;
46895
+ }
46896
+ function assertBalanceContinuity(txns) {
46897
+ let checked = 0;
46898
+ let bad = 0;
46899
+ for (let i2 = 1;i2 < txns.length; i2++) {
46900
+ const prev = txns[i2 - 1].closingBalance;
46901
+ const cur = txns[i2].closingBalance;
46902
+ if (prev == null || cur == null)
46903
+ continue;
46904
+ checked++;
46905
+ const expected = prev + (txns[i2].type === "credit" ? txns[i2].amount : -txns[i2].amount);
46906
+ if (Math.abs(expected - cur) > 0.02)
46907
+ bad++;
46908
+ }
46909
+ if (checked >= 5 && bad / checked > 0.25) {
46910
+ throw new Error(`HDFC savings PDF: column layout not recognised — ${bad}/${checked} rows fail the running-balance check. Refusing to return misread amounts.`);
46911
+ }
46912
+ }
46913
+ function parseTransactions3(items, layout) {
46914
+ const dataItems = items.filter((i2) => i2.y > Y_DATA_MIN2 && i2.y <= Y_DATA_MAX);
46915
+ const rows = buildRows3(dataItems);
46916
+ const transactions = [];
46917
+ let pending = null;
46918
+ for (const row of rows) {
46919
+ const {
46920
+ dateCell,
46921
+ narrationCells,
46922
+ refCell,
46923
+ valueDtCell,
46924
+ withdrawalCell,
46925
+ depositCell,
46926
+ balanceCell
46927
+ } = classifyRow(row, layout);
46928
+ const dateStr = dateCell?.str.trim() ?? "";
46929
+ const isDateRow = /^\d{2}\/\d{2}\/\d{2,4}$/.test(dateStr);
46930
+ const isColumnHeader = narrationCells.some((c2) => c2.str === "Narration" || c2.str === "Date");
46931
+ if (isColumnHeader)
46932
+ continue;
46933
+ if (isDateRow) {
46934
+ if (pending) {
46935
+ const txn = buildTransaction(pending);
46936
+ if (txn)
46937
+ transactions.push(txn);
46938
+ }
46939
+ pending = {
46940
+ dateStr,
46941
+ narrationParts: narrationCells.map((c2) => c2.str),
46942
+ refNo: refCell?.str.trim() ?? null,
46943
+ valueDateStr: valueDtCell?.str.trim() ?? null,
46944
+ withdrawalStr: withdrawalCell?.str.trim() ?? null,
46945
+ depositStr: depositCell?.str.trim() ?? null,
46946
+ balanceStr: balanceCell?.str.trim() ?? null
46947
+ };
46948
+ } else if (narrationCells.length > 0 && !refCell && !withdrawalCell && !depositCell) {
46949
+ const continuationText = narrationCells.map((c2) => c2.str).join(" ");
46950
+ if (/STATEMENT SUMMARY/i.test(continuationText))
46951
+ break;
46952
+ if (pending) {
46953
+ pending.narrationParts.push(...narrationCells.map((c2) => c2.str));
46954
+ }
46955
+ }
46956
+ }
46957
+ if (pending) {
46958
+ const txn = buildTransaction(pending);
46959
+ if (txn)
46960
+ transactions.push(txn);
46961
+ }
46962
+ return transactions;
46963
+ }
46964
+ function buildRows3(items) {
46965
+ const buckets = new Map;
46966
+ for (const item of items) {
46967
+ const pagePrefix = `${item.page}:`;
46968
+ let bucketKey = null;
46969
+ for (const key of buckets.keys()) {
46970
+ if (!key.startsWith(pagePrefix))
46971
+ continue;
46972
+ const bucketY = parseInt(key.slice(pagePrefix.length), 10);
46973
+ if (Math.abs(item.y - bucketY) <= ROW_TOLERANCE3) {
46974
+ bucketKey = key;
46975
+ break;
46976
+ }
46977
+ }
46978
+ if (bucketKey === null) {
46979
+ bucketKey = `${item.page}:${item.y}`;
46980
+ buckets.set(bucketKey, [item]);
46981
+ } else {
46982
+ const existing = buckets.get(bucketKey);
46983
+ if (existing)
46984
+ existing.push(item);
46985
+ }
46986
+ }
46987
+ return [...buckets.entries()].sort((a2, b2) => {
46988
+ const [pa, ya] = a2[0].split(":").map(Number);
46989
+ const [pb, yb] = b2[0].split(":").map(Number);
46990
+ return pa !== pb ? pa - pb : yb - ya;
46991
+ }).map(([, row]) => row.sort((r1, r2) => r1.x - r2.x));
46992
+ }
46993
+ function buildTransaction(row) {
46994
+ const date4 = parseHdfcDate(row.dateStr);
46995
+ if (!date4)
46996
+ return null;
46997
+ let narration = row.narrationParts.join(" ").replace(/\s{2,}/g, " ").trim();
46998
+ let embeddedValueDate = null;
46999
+ const vd = narration.match(/\s*Value\s*Dt\s*(\d{2}\/\d{2}\/\d{2,4})/i);
47000
+ if (vd) {
47001
+ embeddedValueDate = parseHdfcDate(vd[1]);
47002
+ narration = narration.replace(vd[0], "").replace(/\s{2,}/g, " ").trim();
47003
+ }
47004
+ if (!narration)
47005
+ return null;
47006
+ const withdrawal = parseAmount3(row.withdrawalStr);
47007
+ const deposit = parseAmount3(row.depositStr);
47008
+ if (withdrawal === 0 && deposit === 0)
47009
+ return null;
47010
+ const amount = withdrawal > 0 ? withdrawal : deposit;
47011
+ const type = withdrawal > 0 ? "debit" : "credit";
47012
+ const valueDate = row.valueDateStr ? parseHdfcDate(row.valueDateStr) : embeddedValueDate;
47013
+ const closingBalance = parseAmount3(row.balanceStr) || null;
47014
+ const parsed = parseHdfcNarration(narration);
47015
+ return {
47016
+ date: date4,
47017
+ description: narration,
47018
+ amount,
47019
+ type,
47020
+ referenceNumber: row.refNo || null,
47021
+ valueDate,
47022
+ closingBalance,
47023
+ paymentMethod: parsed.paymentMethod,
47024
+ extractedName: parsed.name,
47025
+ upiId: parsed.upiId,
47026
+ sourceCategory: null
47027
+ };
47028
+ }
47029
+ function parseHdfcDate(raw) {
47030
+ const match = raw.trim().match(/^(\d{2})\/(\d{2})\/(\d{2,4})$/);
47031
+ if (!match)
47032
+ return null;
47033
+ const day = match[1];
47034
+ const month = match[2];
47035
+ let year = match[3];
47036
+ if (year.length === 2) {
47037
+ const num = parseInt(year, 10);
47038
+ year = num > 50 ? `19${year}` : `20${year}`;
47039
+ }
47040
+ return `${year}-${month}-${day}`;
47041
+ }
47042
+ function parseAmount3(raw) {
47043
+ if (!raw)
47044
+ return 0;
47045
+ const cleaned = raw.replace(/,/g, "").trim();
47046
+ const num = parseFloat(cleaned);
47047
+ return Number.isNaN(num) ? 0 : Math.abs(num);
47048
+ }
47049
+ // ../parsers/src/sbi/savings-pdf.ts
47050
+ var _pdfjs4 = null;
47051
+ async function getPdfjs4() {
47052
+ if (!_pdfjs4) {
47053
+ _pdfjs4 = await import("pdfjs-dist/legacy/build/pdf.mjs");
47054
+ _pdfjs4.GlobalWorkerOptions.workerSrc = import.meta.resolve("pdfjs-dist/legacy/build/pdf.worker.mjs");
47055
+ }
47056
+ return _pdfjs4;
47057
+ }
47058
+ var X_DATE_MAX3 = 60;
47059
+ var X_VALUEDT_MIN2 = 61;
47060
+ var X_VALUEDT_MAX = 120;
47061
+ var X_NARRATION_MIN2 = 120;
47062
+ var X_NARRATION_MAX2 = 285;
47063
+ var X_DEBIT_MIN = 310;
47064
+ var X_DEBIT_MAX = 395;
47065
+ var X_CREDIT_MIN = 395;
47066
+ var X_CREDIT_MAX = 465;
47067
+ var X_BALANCE_MIN2 = 465;
47068
+ var Y_FOOTER_MIN = 80;
47069
+ var ROW_TOLERANCE4 = 4;
47070
+ var NARRATION_ABOVE_TOLERANCE = 12;
47071
+ async function parseSbiSavingsPdf(data, password) {
47072
+ const pdfjs = await getPdfjs4();
47073
+ const loadOptions = {
47074
+ data: new Uint8Array(data),
47075
+ useWorkerFetch: false,
47076
+ isEvalSupported: false,
47077
+ useSystemFonts: true
47078
+ };
47079
+ if (password)
47080
+ loadOptions.password = password;
47081
+ const pdf = await pdfjs.getDocument(loadOptions).promise;
47082
+ const items = await extractTextItems4(pdf);
47083
+ const metadata = extractMetadata4(items);
47084
+ const transactions = parseTransactions4(items);
47085
+ return {
47086
+ bank: "sbi",
47087
+ accountType: metadata.accountType,
47088
+ accountNumber: metadata.accountLast4,
47089
+ periodStart: metadata.periodStart,
47090
+ periodEnd: metadata.periodEnd,
47091
+ transactions
47092
+ };
47093
+ }
47094
+ async function extractTextItems4(pdf) {
47095
+ const result = [];
47096
+ for (let p2 = 1;p2 <= pdf.numPages; p2++) {
47097
+ const page = await pdf.getPage(p2);
47098
+ const content = await page.getTextContent();
47099
+ for (const item of content.items) {
47100
+ const ti = item;
47101
+ if (typeof ti.str !== "string" || !ti.str.trim())
47102
+ continue;
47103
+ result.push({
47104
+ page: p2,
47105
+ x: Math.round(ti.transform[4]),
47106
+ y: Math.round(ti.transform[5]),
47107
+ str: ti.str
47108
+ });
47109
+ }
47110
+ }
47111
+ return result;
47112
+ }
47113
+ function extractMetadata4(items) {
47114
+ const page1Items = items.filter((i2) => i2.page === 1);
47115
+ const rowStrings = groupIntoRowStrings3(page1Items);
47116
+ const fullText = rowStrings.join(" ");
47117
+ let accountType = "savings";
47118
+ let accountLast4 = null;
47119
+ let periodStart = null;
47120
+ let periodEnd = null;
47121
+ const accountMatch = fullText.match(/Account Number\s*:\s*(\d{8,18})/);
47122
+ if (accountMatch) {
47123
+ accountLast4 = accountMatch[1].slice(-4);
47124
+ }
47125
+ if (/current.*account/i.test(fullText)) {
47126
+ accountType = "current";
47127
+ }
47128
+ const periodMatch = fullText.match(/Statement From\s*:\s*(\d{2}-\d{2}-\d{4})\s+to\s+(\d{2}-\d{2}-\d{4})/i);
47129
+ if (periodMatch) {
47130
+ periodStart = parseSbiDate(periodMatch[1]);
47131
+ periodEnd = parseSbiDate(periodMatch[2]);
47132
+ }
47133
+ return { accountType, accountLast4, periodStart, periodEnd };
47134
+ }
47135
+ function groupIntoRowStrings3(items) {
47136
+ const buckets = new Map;
47137
+ for (const item of items) {
47138
+ const pagePrefix = `${item.page}:`;
47139
+ let bucketKey = null;
47140
+ for (const key of buckets.keys()) {
47141
+ if (!key.startsWith(pagePrefix))
47142
+ continue;
47143
+ if (Math.abs(item.y - parseInt(key.slice(pagePrefix.length), 10)) <= ROW_TOLERANCE4) {
47144
+ bucketKey = key;
47145
+ break;
47146
+ }
47147
+ }
47148
+ if (bucketKey === null) {
47149
+ bucketKey = `${item.page}:${item.y}`;
47150
+ buckets.set(bucketKey, [item]);
47151
+ } else {
47152
+ const existing = buckets.get(bucketKey);
47153
+ if (existing)
47154
+ existing.push(item);
47155
+ }
47156
+ }
47157
+ return [...buckets.values()].map((row) => row.sort((a2, b2) => a2.x - b2.x).map((c2) => c2.str).join(" "));
47158
+ }
47159
+ function parseTransactions4(items) {
47160
+ const dataItems = items.filter((i2) => i2.y > Y_FOOTER_MIN);
47161
+ const rows = buildRows4(dataItems);
47162
+ const dateRows = [];
47163
+ for (const row of rows) {
47164
+ const dateCell = row.find((c2) => c2.x <= X_DATE_MAX3 && /^\d{2}\/\d{2}\/\d{4}$/.test(c2.str.trim()));
47165
+ if (dateCell) {
47166
+ dateRows.push({ page: dateCell.page, y: dateCell.y, row });
47167
+ }
47168
+ }
47169
+ const buckets = new Map;
47170
+ for (const { page, y: y2, row } of dateRows) {
47171
+ const key = `${page}:${y2}`;
47172
+ const dateCell = row.find((c2) => c2.x <= X_DATE_MAX3);
47173
+ if (!dateCell)
47174
+ continue;
47175
+ const valueDtCell = row.find((c2) => c2.x >= X_VALUEDT_MIN2 && c2.x < X_VALUEDT_MAX);
47176
+ const debitCell = row.find((c2) => c2.x >= X_DEBIT_MIN && c2.x < X_DEBIT_MAX && c2.str.trim() !== "-");
47177
+ const creditCell = row.find((c2) => c2.x >= X_CREDIT_MIN && c2.x < X_CREDIT_MAX && c2.str.trim() !== "-");
47178
+ const balanceCell = row.find((c2) => c2.x >= X_BALANCE_MIN2);
47179
+ buckets.set(key, {
47180
+ page,
47181
+ dateY: y2,
47182
+ dateStr: dateCell.str.trim(),
47183
+ valueDateStr: valueDtCell?.str.trim() ?? null,
47184
+ debitStr: debitCell?.str.trim() ?? null,
47185
+ creditStr: creditCell?.str.trim() ?? null,
47186
+ balanceStr: balanceCell?.str.trim() ?? null,
47187
+ narrationParts: []
47188
+ });
47189
+ }
47190
+ const narrationItems = dataItems.filter((i2) => i2.x >= X_NARRATION_MIN2 && i2.x < X_NARRATION_MAX2);
47191
+ for (const item of narrationItems) {
47192
+ const yMin = item.y - NARRATION_ABOVE_TOLERANCE;
47193
+ let bestKey = null;
47194
+ let bestY = Number.MAX_SAFE_INTEGER;
47195
+ for (const { page, y: y2 } of dateRows) {
47196
+ if (page !== item.page)
47197
+ continue;
47198
+ if (y2 >= yMin && y2 < bestY) {
47199
+ bestY = y2;
47200
+ bestKey = `${page}:${y2}`;
47201
+ }
47202
+ }
47203
+ if (bestKey) {
47204
+ const bucket = buckets.get(bestKey);
47205
+ if (bucket)
47206
+ bucket.narrationParts.push(item.str);
47207
+ }
47208
+ }
47209
+ const sortedBuckets = [...buckets.values()].sort((a2, b2) => a2.page !== b2.page ? a2.page - b2.page : b2.dateY - a2.dateY);
47210
+ const transactions = [];
47211
+ for (const bucket of sortedBuckets) {
47212
+ const txn = buildTransaction2(bucket);
47213
+ if (txn)
47214
+ transactions.push(txn);
47215
+ }
47216
+ return transactions;
47217
+ }
47218
+ function buildRows4(items) {
47219
+ const buckets = new Map;
47220
+ for (const item of items) {
47221
+ const pagePrefix = `${item.page}:`;
47222
+ let bucketKey = null;
47223
+ for (const key of buckets.keys()) {
47224
+ if (!key.startsWith(pagePrefix))
47225
+ continue;
47226
+ const bucketY = parseInt(key.slice(pagePrefix.length), 10);
47227
+ if (Math.abs(item.y - bucketY) <= ROW_TOLERANCE4) {
47228
+ bucketKey = key;
47229
+ break;
47230
+ }
47231
+ }
47232
+ if (bucketKey === null) {
47233
+ bucketKey = `${item.page}:${item.y}`;
47234
+ buckets.set(bucketKey, [item]);
47235
+ } else {
47236
+ const existing = buckets.get(bucketKey);
47237
+ if (existing)
47238
+ existing.push(item);
47239
+ }
47240
+ }
47241
+ return [...buckets.entries()].sort((a2, b2) => {
47242
+ const [pa, ya] = a2[0].split(":").map(Number);
47243
+ const [pb, yb] = b2[0].split(":").map(Number);
47244
+ return pa !== pb ? pa - pb : yb - ya;
47245
+ }).map(([, row]) => row.sort((r1, r2) => r1.x - r2.x));
47246
+ }
47247
+ function buildTransaction2(bucket) {
47248
+ const date4 = parseSbiDate(bucket.dateStr);
47249
+ if (!date4)
47250
+ return null;
47251
+ const terminalIdx = bucket.narrationParts.findIndex((p2) => /^\d+\s+AT\s+\d+/.test(p2.trim()));
47252
+ const parts = terminalIdx >= 0 ? bucket.narrationParts.slice(0, terminalIdx + 1) : bucket.narrationParts;
47253
+ const narration = parts.join(" ").replace(/\s{2,}/g, " ").trim();
47254
+ if (!narration)
47255
+ return null;
47256
+ const debit = parseAmount4(bucket.debitStr);
47257
+ const credit = parseAmount4(bucket.creditStr);
47258
+ if (debit === 0 && credit === 0)
47259
+ return null;
47260
+ const amount = debit > 0 ? debit : credit;
47261
+ const type = debit > 0 ? "debit" : "credit";
47262
+ const valueDate = bucket.valueDateStr ? parseSbiDate(bucket.valueDateStr) : null;
47263
+ const closingBalance = parseAmount4(bucket.balanceStr) || null;
47264
+ const parsed = parseSbiNarration(narration);
47265
+ const upiRefMatch = narration.match(/UPI\/(?:DR|CR)\/(\d+)\//i);
47266
+ const referenceNumber = upiRefMatch ? upiRefMatch[1] : null;
47267
+ return {
47268
+ date: date4,
47269
+ description: narration,
47270
+ amount,
47271
+ type,
47272
+ referenceNumber,
47273
+ valueDate,
47274
+ closingBalance,
47275
+ paymentMethod: parsed.paymentMethod,
47276
+ extractedName: parsed.name,
47277
+ upiId: parsed.upiId,
47278
+ sourceCategory: null
47279
+ };
47280
+ }
47281
+ function parseSbiDate(raw) {
47282
+ if (!raw)
47283
+ return null;
47284
+ const match = raw.trim().match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/);
47285
+ if (!match)
47286
+ return null;
47287
+ return `${match[3]}-${match[2]}-${match[1]}`;
47288
+ }
47289
+ function parseAmount4(raw) {
47290
+ if (!raw)
47291
+ return 0;
47292
+ const cleaned = raw.replace(/,/g, "").trim();
47293
+ const num = parseFloat(cleaned);
47294
+ return Number.isNaN(num) ? 0 : Math.abs(num);
47295
+ }
47296
+ // src/lib/txn-rows.ts
47297
+ function toTxnRow(r2) {
47298
+ return {
47299
+ id: String(r2.id),
47300
+ date: String(r2.date),
47301
+ amount: Number(r2.amount),
47302
+ type: r2.type === "credit" ? "credit" : "debit",
47303
+ description: String(r2.description ?? ""),
47304
+ merchantId: r2.merchantId ?? null,
47305
+ personId: r2.personId ?? null,
47306
+ categoryId: r2.categoryId ?? null,
47307
+ isTransfer: !!r2.isTransfer,
47308
+ isIgnored: !!r2.isIgnored,
47309
+ isDuplicate: !!r2.isDuplicate
47310
+ };
47311
+ }
47312
+ async function fetchAllRows(client, crypto3, params = {}, maxPages = 100) {
47313
+ return (await fetchAllRowsPaged(client, crypto3, params, maxPages)).rows;
47314
+ }
47315
+ async function fetchAllRowsPaged(client, crypto3, params = {}, maxPages = 100) {
47316
+ const out = [];
47317
+ let cursor;
47318
+ let truncated = false;
47319
+ for (let page = 0;page < maxPages; page++) {
47320
+ const q2 = { ...params, limit: 200, view: "match" };
47321
+ if (cursor)
47322
+ q2.cursor = cursor;
47323
+ const res = await client.get("/api/transactions", q2);
47324
+ const batch = res.data ?? [];
47325
+ if (crypto3)
47326
+ await decryptTransactionFields(batch, crypto3.key);
47327
+ out.push(...batch.map(toTxnRow));
47328
+ if (!res.hasMore || !res.nextCursor || batch.length === 0 || res.nextCursor === cursor)
47329
+ break;
47330
+ cursor = res.nextCursor;
47331
+ if (page === maxPages - 1)
47332
+ truncated = true;
47333
+ }
47334
+ return { rows: out, truncated };
47335
+ }
47336
+ var MAX_DESC = 70;
47337
+ function toLeanRow(r2, personNames) {
47338
+ const merchant = r2.merchant;
47339
+ const category = r2.category;
47340
+ const merchantName = r2.merchantName ?? merchant?.cleanName;
47341
+ const categorySlug = typeof category === "string" ? category : category?.slug;
47342
+ const personId = r2.personId;
47343
+ const person = personId ? personNames.get(personId) ?? null : null;
47344
+ let flags = r2.flags;
47345
+ if (!flags) {
47346
+ flags = [];
47347
+ if (r2.isTransfer)
47348
+ flags.push("transfer");
47349
+ if (r2.isIgnored)
47350
+ flags.push("ignored");
47351
+ if (r2.isDuplicate)
47352
+ flags.push("duplicate");
47353
+ }
47354
+ const desc = String(r2.description ?? "");
47355
+ return {
47356
+ id: r2.id,
47357
+ date: r2.date,
47358
+ amt: Number(r2.amount),
47359
+ t: r2.type === "credit" ? "cr" : "dr",
47360
+ desc: desc.length > MAX_DESC ? `${desc.slice(0, MAX_DESC)}…` : desc,
47361
+ ...categorySlug ? { cat: categorySlug } : {},
47362
+ ...merchantName ? { merchant: merchantName } : {},
47363
+ ...person ? { person } : {},
47364
+ ...flags.length ? { flags } : {}
47365
+ };
47366
+ }
47367
+
45629
47368
  // src/lib/person-backfill.ts
45630
47369
  function normalizePersonName(name) {
45631
47370
  return name.toLowerCase().trim().replace(/\s+/g, " ");
@@ -45640,141 +47379,792 @@ async function loadDecryptedPersons(client, crypto3) {
45640
47379
  name = (await decryptPersonCipher(p2.cipher, crypto3.key)).name;
45641
47380
  } catch {}
45642
47381
  }
45643
- if (name)
45644
- persons.push({ id: p2.id, name });
45645
- }
45646
- return persons;
47382
+ if (name)
47383
+ persons.push({ id: p2.id, name });
47384
+ }
47385
+ return persons;
47386
+ }
47387
+ function resolvePersonByName(persons, query) {
47388
+ const q2 = normalizePersonName(query);
47389
+ if (!q2)
47390
+ return null;
47391
+ const exact = persons.filter((p2) => normalizePersonName(p2.name) === q2);
47392
+ if (exact.length === 1)
47393
+ return { personId: exact[0].id };
47394
+ if (exact.length > 1)
47395
+ return { candidates: exact.map((p2) => p2.name) };
47396
+ const partial2 = persons.filter((p2) => normalizePersonName(p2.name).includes(q2));
47397
+ if (partial2.length === 1)
47398
+ return { personId: partial2[0].id };
47399
+ if (partial2.length > 1)
47400
+ return { candidates: partial2.map((p2) => p2.name) };
47401
+ return null;
47402
+ }
47403
+ function parseNarration(description) {
47404
+ const hdfc = parseHdfcNarration(description);
47405
+ if (hdfc.name && hdfc.paymentMethod)
47406
+ return { name: hdfc.name, upiId: hdfc.upiId, paymentMethod: hdfc.paymentMethod };
47407
+ const sbi = parseSbiNarration(description);
47408
+ if (sbi.name && sbi.paymentMethod)
47409
+ return { name: sbi.name, upiId: sbi.upiId, paymentMethod: sbi.paymentMethod };
47410
+ return {
47411
+ name: hdfc.name || sbi.name,
47412
+ upiId: hdfc.upiId ?? sbi.upiId,
47413
+ paymentMethod: hdfc.paymentMethod ?? sbi.paymentMethod
47414
+ };
47415
+ }
47416
+ function isPersonLikeNarration(description) {
47417
+ const { name, paymentMethod } = parseNarration(description);
47418
+ return classifyEntity({ paymentMethod, extractedName: name }) !== "merchant";
47419
+ }
47420
+ async function backfillPersons(client, crypto3, opts = {}) {
47421
+ const existing = await loadDecryptedPersons(client, crypto3);
47422
+ const personIdByNorm = new Map;
47423
+ for (const p2 of existing)
47424
+ personIdByNorm.set(normalizePersonName(p2.name), p2.id);
47425
+ const { rows, truncated } = await fetchAllRowsPaged(client, crypto3, { unresolved: "true" });
47426
+ const candidates = rows.filter((r2) => !r2.merchantId && !r2.personId && !r2.isTransfer && !r2.isIgnored);
47427
+ const groups = new Map;
47428
+ for (const t2 of candidates) {
47429
+ const { name, upiId, paymentMethod } = parseNarration(t2.description);
47430
+ if (!name)
47431
+ continue;
47432
+ if (classifyEntity({ paymentMethod, extractedName: name }) !== "person")
47433
+ continue;
47434
+ const norm = normalizePersonName(name);
47435
+ if (!norm)
47436
+ continue;
47437
+ const g3 = groups.get(norm) ?? {
47438
+ displayName: name.trim(),
47439
+ upiHandles: new Set,
47440
+ txnIds: []
47441
+ };
47442
+ if (upiId)
47443
+ g3.upiHandles.add(upiId);
47444
+ g3.txnIds.push(t2.id);
47445
+ groups.set(norm, g3);
47446
+ }
47447
+ const plan = [];
47448
+ const newGroups = [];
47449
+ let reused = 0;
47450
+ for (const [norm, g3] of groups) {
47451
+ const upiHandles = [...g3.upiHandles];
47452
+ const isReuse = personIdByNorm.has(norm);
47453
+ plan.push({
47454
+ name: g3.displayName,
47455
+ normalized: norm,
47456
+ txnCount: g3.txnIds.length,
47457
+ upiHandles,
47458
+ action: isReuse ? "reuse" : "create"
47459
+ });
47460
+ if (isReuse)
47461
+ reused++;
47462
+ else
47463
+ newGroups.push({ norm, displayName: g3.displayName, upiHandles });
47464
+ }
47465
+ if (!opts.dryRun && newGroups.length > 0) {
47466
+ const bodies = await Promise.all(newGroups.map(async ({ displayName, upiHandles }) => {
47467
+ const fields = {
47468
+ name: displayName,
47469
+ upiHandles,
47470
+ phoneNumbers: [],
47471
+ relationship: null,
47472
+ notes: null
47473
+ };
47474
+ return crypto3 ? await encryptPersonCipher(fields, crypto3.key) : { name: displayName, upiHandles, phoneNumbers: [], relationship: null, notes: null };
47475
+ }));
47476
+ const res = await client.post("/api/persons/bulk", {
47477
+ persons: bodies
47478
+ });
47479
+ res.data.forEach((p2, i2) => {
47480
+ personIdByNorm.set(newGroups[i2].norm, p2.id);
47481
+ });
47482
+ }
47483
+ const created = opts.dryRun ? 0 : newGroups.length;
47484
+ const links = [];
47485
+ if (!opts.dryRun) {
47486
+ for (const [norm, g3] of groups) {
47487
+ const personId = personIdByNorm.get(norm);
47488
+ if (!personId)
47489
+ continue;
47490
+ for (const txnId of g3.txnIds)
47491
+ links.push({ transactionId: txnId, personId });
47492
+ }
47493
+ }
47494
+ if (!opts.dryRun && links.length > 0) {
47495
+ const CHUNK = 25;
47496
+ for (let i2 = 0;i2 < links.length; i2 += CHUNK) {
47497
+ await client.post("/api/transactions/bulk-link-entity", { links: links.slice(i2, i2 + CHUNK) });
47498
+ }
47499
+ }
47500
+ return {
47501
+ dryRun: !!opts.dryRun,
47502
+ truncated,
47503
+ candidatesScanned: candidates.length,
47504
+ created,
47505
+ reused,
47506
+ linked: links.length,
47507
+ plan
47508
+ };
47509
+ }
47510
+
47511
+ // src/lib/categorize-groups.ts
47512
+ var NOISE2 = new Set([
47513
+ "upi",
47514
+ "pos",
47515
+ "imps",
47516
+ "neft",
47517
+ "rtgs",
47518
+ "ach",
47519
+ "nach",
47520
+ "ecs",
47521
+ "txn",
47522
+ "transaction",
47523
+ "payment",
47524
+ "paid",
47525
+ "pay",
47526
+ "to",
47527
+ "from",
47528
+ "ref",
47529
+ "refno",
47530
+ "debit",
47531
+ "credit",
47532
+ "card",
47533
+ "mob",
47534
+ "mobile",
47535
+ "bank",
47536
+ "transfer",
47537
+ "trf",
47538
+ "by",
47539
+ "for",
47540
+ "the",
47541
+ "via",
47542
+ "ecom",
47543
+ "purchase",
47544
+ "online",
47545
+ "inr",
47546
+ "rs",
47547
+ "towards",
47548
+ "ibl",
47549
+ "ybl",
47550
+ "oksbi",
47551
+ "okhdfcbank",
47552
+ "okicici",
47553
+ "okaxis",
47554
+ "axl",
47555
+ "apl",
47556
+ "paytm",
47557
+ "icici",
47558
+ "hdfc",
47559
+ "sbi",
47560
+ "axis",
47561
+ "utib",
47562
+ "sbin",
47563
+ "ifsc",
47564
+ "vpa",
47565
+ "bill",
47566
+ "cas",
47567
+ "dr",
47568
+ "cr"
47569
+ ]);
47570
+ var GOOD_TOKEN = /^[a-z][a-z&']{2,}$/;
47571
+ var MAX_RUN = 3;
47572
+ function descriptionPattern(description) {
47573
+ const tokens = normaliseName(description).split(" ").filter(Boolean);
47574
+ const run = [];
47575
+ for (const tok of tokens) {
47576
+ const good = GOOD_TOKEN.test(tok) && !NOISE2.has(tok);
47577
+ if (!good) {
47578
+ if (run.length)
47579
+ break;
47580
+ continue;
47581
+ }
47582
+ if (run.includes(tok))
47583
+ break;
47584
+ run.push(tok);
47585
+ if (run.length === MAX_RUN)
47586
+ break;
47587
+ }
47588
+ return run.join(" ");
47589
+ }
47590
+ var UNPARSED = "(unparsed)";
47591
+ function groupRows(rows) {
47592
+ const map2 = new Map;
47593
+ for (const r2 of rows) {
47594
+ const pattern = descriptionPattern(r2.description) || UNPARSED;
47595
+ const key = `${pattern}|${r2.type}`;
47596
+ const g3 = map2.get(key) ?? { pattern, type: r2.type, rows: [], total: 0 };
47597
+ g3.rows.push(r2);
47598
+ g3.total += r2.amount;
47599
+ map2.set(key, g3);
47600
+ }
47601
+ return [...map2.values()].sort((a2, b2) => b2.total - a2.total);
47602
+ }
47603
+ function isCategorizable(r2) {
47604
+ return !r2.categoryId && !r2.isTransfer && !r2.isIgnored && !r2.isDuplicate;
47605
+ }
47606
+ async function fetchUncategorized(client, crypto3, range2 = {}) {
47607
+ const params = { uncategorized: "true" };
47608
+ if (range2.startDate)
47609
+ params.startDate = range2.startDate;
47610
+ if (range2.endDate)
47611
+ params.endDate = range2.endDate;
47612
+ return (await fetchAllRows(client, crypto3, params)).filter(isCategorizable);
47613
+ }
47614
+ var MERCHANT_CREATE_CONCURRENCY = 5;
47615
+ var titleCase = (s2) => s2.replace(/\b[a-z]/g, (c2) => c2.toUpperCase());
47616
+ async function applyGroupAssignments(client, crypto3, assignments, opts = {}) {
47617
+ const catIndex = await loadCategoryIndex(client);
47618
+ const resolved = assignments.map((a2) => ({ a: a2, cat: requireCategory(catIndex, a2.categorySlug) }));
47619
+ const groups = groupRows(await fetchUncategorized(client, crypto3, opts));
47620
+ const result = { applied: [], notFound: [], rulesSaved: 0, unlearnable: [] };
47621
+ const plan = [];
47622
+ const claimed = new Set;
47623
+ for (const { a: a2, cat } of resolved) {
47624
+ const hits = groups.filter((g3) => g3.pattern === a2.pattern && (!a2.type || g3.type === a2.type) && !claimed.has(g3));
47625
+ if (hits.length === 0) {
47626
+ result.notFound.push(a2.pattern);
47627
+ continue;
47628
+ }
47629
+ for (const g3 of hits) {
47630
+ claimed.add(g3);
47631
+ plan.push({ group: g3, categoryId: cat.id, slug: cat.slug, learn: a2.learn !== false });
47632
+ }
47633
+ }
47634
+ if (opts.dryRun) {
47635
+ result.applied = plan.map((p2) => ({
47636
+ pattern: p2.group.pattern,
47637
+ type: p2.group.type,
47638
+ rows: p2.group.rows.length,
47639
+ category: p2.slug
47640
+ }));
47641
+ return result;
47642
+ }
47643
+ if (plan.length === 0)
47644
+ return result;
47645
+ const learnable = plan.filter((p2) => p2.learn && p2.group.pattern !== UNPARSED);
47646
+ const merchantByRawId = new Map;
47647
+ if (learnable.length > 0) {
47648
+ const ms = await client.get("/api/merchants");
47649
+ for (const m2 of ms.data ?? [])
47650
+ if (m2.rawId && m2.householdId)
47651
+ merchantByRawId.set(m2.rawId, m2.id);
47652
+ }
47653
+ const { bag, version: version2 } = await loadBag(client);
47654
+ const now = new Date().toISOString();
47655
+ const links = [];
47656
+ const direct = [];
47657
+ const taught = new Map;
47658
+ const learnPlans = [];
47659
+ const directPlans = [];
47660
+ for (const p2 of plan) {
47661
+ const { group, learn } = p2;
47662
+ if (!learn || group.pattern === UNPARSED) {
47663
+ directPlans.push({ p: p2, reason: learn ? UNPARSED : undefined });
47664
+ } else if (crypto3 && group.rows.some((r2) => isPersonLikeNarration(r2.description))) {
47665
+ directPlans.push({ p: p2, reason: `${group.pattern} (private mode: person-like)` });
47666
+ } else if (taught.has(group.pattern) && taught.get(group.pattern) !== p2.categoryId) {
47667
+ directPlans.push({ p: p2, reason: `${group.pattern} (conflicting category for same pattern)` });
47668
+ } else {
47669
+ taught.set(group.pattern, p2.categoryId);
47670
+ learnPlans.push(p2);
47671
+ }
47672
+ }
47673
+ for (const { p: p2, reason } of directPlans) {
47674
+ if (reason)
47675
+ result.unlearnable.push(reason);
47676
+ direct.push({ ids: p2.group.rows.map((r2) => r2.id), categoryId: p2.categoryId });
47677
+ result.applied.push({
47678
+ pattern: p2.group.pattern,
47679
+ type: p2.group.type,
47680
+ rows: p2.group.rows.length,
47681
+ category: p2.slug
47682
+ });
47683
+ }
47684
+ const toCreate = new Map;
47685
+ for (const p2 of learnPlans) {
47686
+ const rawId = `pattern:${p2.group.pattern}`;
47687
+ if (!merchantByRawId.has(rawId) && !toCreate.has(rawId))
47688
+ toCreate.set(rawId, p2);
47689
+ }
47690
+ await mapLimit([...toCreate], MERCHANT_CREATE_CONCURRENCY, async ([rawId, p2]) => {
47691
+ const created = await client.post("/api/merchants", {
47692
+ rawId,
47693
+ cleanName: titleCase(p2.group.pattern),
47694
+ categoryId: p2.categoryId
47695
+ });
47696
+ merchantByRawId.set(rawId, created.data.id);
47697
+ });
47698
+ for (const { group, categoryId, slug } of learnPlans) {
47699
+ result.applied.push({
47700
+ pattern: group.pattern,
47701
+ type: group.type,
47702
+ rows: group.rows.length,
47703
+ category: slug
47704
+ });
47705
+ const merchantId = merchantByRawId.get(`pattern:${group.pattern}`);
47706
+ if (!bag.merchantAliases.some((al) => al.pattern === group.pattern)) {
47707
+ bag.merchantAliases.push({ pattern: group.pattern, merchantId, createdAt: now });
47708
+ }
47709
+ bag.merchantRules[merchantId] = { categoryId, lastUserCategoryAt: now };
47710
+ result.rulesSaved += 1;
47711
+ const withPerson = group.rows.filter((r2) => r2.personId);
47712
+ if (withPerson.length)
47713
+ direct.push({ ids: withPerson.map((r2) => r2.id), categoryId });
47714
+ for (const r2 of group.rows) {
47715
+ if (!r2.personId)
47716
+ links.push({ transactionId: r2.id, merchantId, categoryId });
47717
+ }
47718
+ }
47719
+ if (result.rulesSaved > 0)
47720
+ await saveBag(client, bag, version2);
47721
+ await postLinks(client, links);
47722
+ for (const { ids, categoryId } of direct) {
47723
+ const categoryType = catIndex.byId.get(categoryId)?.type;
47724
+ for (let i2 = 0;i2 < ids.length; i2 += 200) {
47725
+ await client.patch("/api/transactions/bulk", {
47726
+ ids: ids.slice(i2, i2 + 200),
47727
+ categoryId,
47728
+ ...categoryType ? { categoryType } : {}
47729
+ });
47730
+ }
47731
+ }
47732
+ return result;
47733
+ }
47734
+
47735
+ // src/lib/jev-categorize.ts
47736
+ var JEV_MODEL = "jev-1.13.0";
47737
+ var DEFAULT_MIN_CONFIDENCE = 0.95;
47738
+ var TIMEOUT_MS = 8000;
47739
+ var CONCURRENCY = 8;
47740
+ var MIN_LEARN_PATTERN_LEN = 5;
47741
+ function assertPrivateModeAllowed(crypto3, env2 = process.env) {
47742
+ if (crypto3 && env2.JEV_ALLOW_PRIVATE_MODE !== "1") {
47743
+ throw new Error("Jev categorization is disabled in private mode: it would send transaction text to a third party. " + "To opt in for your own account, set JEV_ALLOW_PRIVATE_MODE=1 in the MCP's environment. " + "Otherwise use get_uncategorized_groups + categorize_groups.");
47744
+ }
47745
+ }
47746
+ function assertJevAllowed(crypto3, env2 = process.env) {
47747
+ assertPrivateModeAllowed(crypto3, env2);
47748
+ const key = env2.JEV_API_KEY;
47749
+ if (!key)
47750
+ throw new Error("JEV_API_KEY is not set — Jev categorization is off.");
47751
+ return key;
47752
+ }
47753
+ function makeJevClassifier(apiKey) {
47754
+ const client = new TypeSafeClient({ apiKey, timeout: TIMEOUT_MS });
47755
+ return async (state, options) => {
47756
+ const { answers } = await client.systemOne({
47757
+ model: JEV_MODEL,
47758
+ state,
47759
+ questions: {
47760
+ category: choice("Which spending category best fits this bank transaction?", options)
47761
+ }
47762
+ });
47763
+ return { choice: answers.category.choice, confidence: answers.category.confidence };
47764
+ };
47765
+ }
47766
+ var UNSURE = "unsure";
47767
+ function optionsFor(type, index) {
47768
+ const allowed = type === "credit" ? new Set(["income", "transfer"]) : new Set(["needs", "wants", "investments", "transfer"]);
47769
+ const out = {};
47770
+ for (const [slug, c2] of index.bySlug) {
47771
+ if (index.parents.has(slug) || !allowed.has(c2.type))
47772
+ continue;
47773
+ out[slug] = `${c2.type}: ${slug.replace(/_/g, " ")}`;
47774
+ }
47775
+ out[UNSURE] = "unclear, or a payment to an individual person";
47776
+ return out;
47777
+ }
47778
+ function groupState(type, sample) {
47779
+ const dir = type === "credit" ? "Money received" : "Money paid out";
47780
+ return `${dir}. Bank narration: "${sample.slice(0, 80)}"`;
47781
+ }
47782
+ async function jevCategorize(client, crypto3, classify, opts = {}) {
47783
+ assertPrivateModeAllowed(crypto3, opts.env);
47784
+ const min = opts.minConfidence ?? DEFAULT_MIN_CONFIDENCE;
47785
+ const index = await loadCategoryIndex(client);
47786
+ const rows = await fetchUncategorized(client, crypto3, opts);
47787
+ const groups = groupRows(rows);
47788
+ const unparsed = groups.filter((g3) => g3.pattern === UNPARSED).length;
47789
+ const candidates = groups.filter((g3) => g3.pattern !== UNPARSED);
47790
+ let errors4 = 0;
47791
+ const suggestions = await mapLimit(candidates, CONCURRENCY, async (g3) => {
47792
+ try {
47793
+ const options = optionsFor(g3.type, index);
47794
+ const sample = g3.rows[0].description;
47795
+ const text = crypto3 ? g3.pattern : sample;
47796
+ const r2 = await classify(groupState(g3.type, text), options);
47797
+ return r2.choice in options ? { g: g3, ...r2 } : null;
47798
+ } catch {
47799
+ errors4 += 1;
47800
+ return null;
47801
+ }
47802
+ });
47803
+ const accepted = [];
47804
+ const review = [];
47805
+ for (const s2 of suggestions) {
47806
+ if (!s2)
47807
+ continue;
47808
+ const isTransfer = index.bySlug.get(s2.choice)?.type === "transfer";
47809
+ if (s2.choice !== UNSURE && !isTransfer && s2.confidence >= min) {
47810
+ accepted.push({
47811
+ pattern: s2.g.pattern,
47812
+ type: s2.g.type,
47813
+ categorySlug: s2.choice,
47814
+ learn: s2.g.pattern.length >= MIN_LEARN_PATTERN_LEN,
47815
+ confidence: s2.confidence
47816
+ });
47817
+ } else {
47818
+ review.push({
47819
+ pattern: s2.g.pattern,
47820
+ type: s2.g.type === "credit" ? "cr" : "dr",
47821
+ best: s2.choice,
47822
+ confidence: Math.round(s2.confidence * 100) / 100,
47823
+ total: Math.round(s2.g.total)
47824
+ });
47825
+ }
47826
+ }
47827
+ review.sort((a2, b2) => b2.total - a2.total);
47828
+ const applied = await applyGroupAssignments(client, crypto3, accepted.map(({ confidence: _c, ...a2 }) => a2), { dryRun: opts.dryRun, startDate: opts.startDate, endDate: opts.endDate });
47829
+ const conf = new Map(accepted.map((a2) => [`${a2.pattern}|${a2.type}`, a2.confidence]));
47830
+ return {
47831
+ dryRun: !!opts.dryRun,
47832
+ applied: applied.applied.map((a2) => ({
47833
+ pattern: a2.pattern,
47834
+ category: a2.category,
47835
+ confidence: Math.round((conf.get(`${a2.pattern}|${a2.type}`) ?? 0) * 100) / 100,
47836
+ rows: a2.rows
47837
+ })),
47838
+ review: review.slice(0, 30),
47839
+ skipped: { unparsed, errors: errors4 },
47840
+ rulesSaved: applied.rulesSaved
47841
+ };
47842
+ }
47843
+
47844
+ // src/tools/jev.ts
47845
+ function registerJevTools(server, client, crypto3) {
47846
+ server.registerTool("auto_categorize_jev", {
47847
+ description: "Auto-categorize uncategorized transactions with Jev (TypeSafe's classifier) — zero LLM tokens, ~ms per group. " + "OPT-IN: needs JEV_API_KEY. Refuses in private mode (it would send transaction text to a third party) unless the owner sets JEV_ALLOW_PRIVATE_MODE=1; in private mode only the sanitized merchant pattern is sent. " + `Groups by merchant pattern; groups at/above \`minConfidence\` (default ${DEFAULT_MIN_CONFIDENCE}) are categorized and a reusable rule is saved; ` + "the rest come back in `review` (top 30 by ₹) for you to decide with `categorize_groups`. Use dryRun to preview.",
47848
+ inputSchema: {
47849
+ startDate: exports_external.string().optional().describe("YYYY-MM-DD"),
47850
+ endDate: exports_external.string().optional().describe("YYYY-MM-DD"),
47851
+ minConfidence: exports_external.number().min(0.5).max(1).optional().describe(`Apply at/above this confidence (default ${DEFAULT_MIN_CONFIDENCE})`),
47852
+ dryRun: exports_external.boolean().optional()
47853
+ }
47854
+ }, async ({ startDate, endDate, minConfidence, dryRun }) => {
47855
+ const key = assertJevAllowed(crypto3);
47856
+ const result = await jevCategorize(client, crypto3, makeJevClassifier(key), {
47857
+ startDate,
47858
+ endDate,
47859
+ minConfidence,
47860
+ dryRun
47861
+ });
47862
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
47863
+ });
47864
+ }
47865
+
47866
+ // src/tools/merchants.ts
47867
+ async function categorySlugToId(client) {
47868
+ const res = await client.get("/api/categories");
47869
+ const map2 = new Map;
47870
+ for (const root of res.data ?? []) {
47871
+ if (root.slug)
47872
+ map2.set(root.slug, root.id);
47873
+ for (const child of root.children ?? []) {
47874
+ if (child.slug)
47875
+ map2.set(child.slug, child.id);
47876
+ }
47877
+ }
47878
+ return map2;
47879
+ }
47880
+ function registerMerchantTools(server, client, crypto3) {
47881
+ server.registerTool("upsert_merchant", {
47882
+ description: "Create or update a merchant in the dictionary. Omit `id` to add a new merchant (future transactions from it are auto-categorised); pass `id` to update an existing merchant's name, category, or flags.",
47883
+ inputSchema: {
47884
+ id: exports_external.string().optional().describe("Merchant UUID — pass to update, omit to create"),
47885
+ rawId: exports_external.string().optional().describe("Raw identifier — UPI ID or normalised name (required when creating)"),
47886
+ cleanName: exports_external.string().optional().describe('Human-friendly name (e.g. "DMart")'),
47887
+ categorySlug: exports_external.string().optional().describe("Category slug, e.g. 'food_delivery'"),
47888
+ isPerson: exports_external.boolean().optional(),
47889
+ isRecurring: exports_external.boolean().optional(),
47890
+ notes: exports_external.string().optional()
47891
+ }
47892
+ }, async ({ id, categorySlug, ...rest }) => {
47893
+ const payload = { ...rest };
47894
+ if (categorySlug) {
47895
+ const slugMap = await categorySlugToId(client);
47896
+ const categoryId = slugMap.get(categorySlug);
47897
+ if (!categoryId) {
47898
+ throw new Error(`Unknown categorySlug "${categorySlug}". Use list_categories to see valid slugs.`);
47899
+ }
47900
+ payload.categoryId = categoryId;
47901
+ }
47902
+ const data = id ? await client.patch(`/api/merchants/${id}`, payload) : await client.post("/api/merchants", payload);
47903
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
47904
+ });
47905
+ server.registerTool("list_merchants", {
47906
+ description: "List merchants in the dictionary with their categories and transaction counts. Pass `rawId` to look up a single merchant by its raw ID (UPI ID or normalised name) instead of listing all (matched client-side).",
47907
+ inputSchema: {
47908
+ rawId: exports_external.string().optional().describe("Raw identifier to look up a single merchant; omit to list all")
47909
+ }
47910
+ }, async ({ rawId }) => {
47911
+ const data = await client.get("/api/merchants");
47912
+ if (crypto3 && data.data)
47913
+ await decryptMerchantFields(data.data, crypto3.key);
47914
+ if (!rawId)
47915
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
47916
+ const needle = rawId.toLowerCase();
47917
+ const merchants = (data.data ?? []).filter((m2) => String(m2.rawId ?? "").toLowerCase() === needle || String(m2.cleanName ?? "").toLowerCase().includes(needle));
47918
+ return {
47919
+ content: [{ type: "text", text: JSON.stringify({ ...data, data: merchants }) }]
47920
+ };
47921
+ });
47922
+ server.registerTool("merge_merchants", {
47923
+ description: "Merge duplicate merchants into one primary merchant. Transactions of the duplicates are re-pointed to the primary (unlike delete_merchant, which unlinks them). Duplicates are removed.",
47924
+ inputSchema: {
47925
+ primaryId: exports_external.string().describe("UUID of the merchant to keep"),
47926
+ duplicateIds: exports_external.array(exports_external.string()).min(1).describe("UUIDs of duplicates to fold into it")
47927
+ }
47928
+ }, async (body) => {
47929
+ const data = await client.post("/api/merchants/merge", body);
47930
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
47931
+ });
47932
+ server.registerTool("delete_merchant", {
47933
+ description: "Delete merchant(s) from the dictionary. Provide `id` for a single merchant or `ids` for several. Transactions referencing a deleted merchant have their merchantId cleared. PERMANENT.",
47934
+ inputSchema: {
47935
+ id: exports_external.string().optional().describe("Single merchant UUID"),
47936
+ ids: exports_external.array(exports_external.string()).min(1).optional().describe("Multiple merchant UUIDs (bulk delete)")
47937
+ }
47938
+ }, async ({ id, ids }) => {
47939
+ if (!id && (!ids || ids.length === 0)) {
47940
+ throw new Error("Provide either id or ids");
47941
+ }
47942
+ const data = ids ? await client.post("/api/merchants/bulk-delete", { ids }) : await client.delete(`/api/merchants/${id}`);
47943
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
47944
+ });
47945
+ }
47946
+
47947
+ // src/tools/net-worth.ts
47948
+ function registerNetWorthTools(server, client) {
47949
+ server.registerTool("get_net_worth", {
47950
+ description: "Current net worth snapshot and history (up to 12 months). Includes liquid cash, investments, emergency fund, liabilities.",
47951
+ inputSchema: {}
47952
+ }, async () => {
47953
+ const data = await client.get("/api/net-worth");
47954
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
47955
+ });
47956
+ server.registerTool("update_net_worth", {
47957
+ description: "Log a net worth snapshot for a specific month. Upserts — safe to call multiple times for the same month. mode=manual (default) records the provided (omitted ones default to 0) liquidCash/investments/emergencyFund/liabilities/breakdown values. mode=auto ignores those and auto-calculates net worth from DB (investments + FDs + accounts) — use it when the data is already in the system.",
47958
+ inputSchema: {
47959
+ mode: exports_external.enum(["manual", "auto"]).default("manual"),
47960
+ month: exports_external.number().min(1).max(12),
47961
+ year: exports_external.number().min(2020).max(2100),
47962
+ liquidCash: exports_external.string().optional().describe('manual mode: defaults to "0"'),
47963
+ investments: exports_external.string().optional().describe('manual mode: defaults to "0"'),
47964
+ emergencyFund: exports_external.string().optional().describe('manual mode: defaults to "0"'),
47965
+ liabilities: exports_external.string().optional(),
47966
+ breakdown: exports_external.record(exports_external.string(), exports_external.string()).optional()
47967
+ }
47968
+ }, async ({ mode, ...body }) => {
47969
+ let data;
47970
+ if (mode === "auto") {
47971
+ data = await client.post("/api/net-worth/auto", { month: body.month, year: body.year });
47972
+ } else {
47973
+ const liquidCash = parseFloat(body.liquidCash ?? "0");
47974
+ const investments = parseFloat(body.investments ?? "0");
47975
+ const emergencyFund = parseFloat(body.emergencyFund ?? "0");
47976
+ const liabilities = parseFloat(body.liabilities ?? "0");
47977
+ const totalNetWorth = (liquidCash + investments + emergencyFund - liabilities).toFixed(2);
47978
+ data = await client.post("/api/net-worth", {
47979
+ ...body,
47980
+ liquidCash: body.liquidCash ?? "0",
47981
+ investments: body.investments ?? "0",
47982
+ emergencyFund: body.emergencyFund ?? "0",
47983
+ totalNetWorth
47984
+ });
47985
+ }
47986
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
47987
+ });
47988
+ }
47989
+
47990
+ // src/tools/persons.ts
47991
+ function registerPersonTools(server, client, crypto3) {
47992
+ server.registerTool("upsert_person", {
47993
+ description: "Create or update a person (UPI counterparty / individual) in this household's encrypted directory. Omit `id` to add a new person; pass `id` to update any combination of an existing person's fields.",
47994
+ inputSchema: {
47995
+ id: exports_external.string().optional().describe("Person UUID — pass to update, omit to create"),
47996
+ name: exports_external.string().optional().describe('Display name (e.g. "Priya") — required when creating'),
47997
+ upiHandles: exports_external.array(exports_external.string()).optional().describe("UPI ids the person uses"),
47998
+ phoneNumbers: exports_external.array(exports_external.string()).optional(),
47999
+ relationship: exports_external.string().optional().describe('e.g. "sister", "friend", "landlord"'),
48000
+ notes: exports_external.string().optional()
48001
+ }
48002
+ }, async ({ id, ...updates }) => {
48003
+ if (crypto3) {
48004
+ if (id) {
48005
+ const existing = await client.get(`/api/persons/${id}`);
48006
+ const personData = existing.data;
48007
+ let current;
48008
+ if (personData.cipher) {
48009
+ current = await decryptPersonCipher(personData.cipher, crypto3.key);
48010
+ } else {
48011
+ current = {
48012
+ name: personData.name,
48013
+ upiHandles: personData.upiHandles,
48014
+ phoneNumbers: personData.phoneNumbers,
48015
+ relationship: personData.relationship,
48016
+ notes: personData.notes
48017
+ };
48018
+ }
48019
+ const merged = {
48020
+ name: updates.name ?? current.name,
48021
+ upiHandles: updates.upiHandles ?? current.upiHandles,
48022
+ phoneNumbers: updates.phoneNumbers ?? current.phoneNumbers,
48023
+ relationship: updates.relationship ?? current.relationship ?? null,
48024
+ notes: updates.notes ?? current.notes ?? null
48025
+ };
48026
+ const payload2 = await encryptPersonCipher(merged, crypto3.key);
48027
+ const data3 = await client.patch(`/api/persons/${id}`, payload2);
48028
+ return { content: [{ type: "text", text: JSON.stringify(data3) }] };
48029
+ }
48030
+ if (!updates.name)
48031
+ throw new Error("name is required when creating a person");
48032
+ const fields = {
48033
+ name: updates.name,
48034
+ upiHandles: updates.upiHandles ?? [],
48035
+ phoneNumbers: updates.phoneNumbers ?? [],
48036
+ relationship: updates.relationship ?? null,
48037
+ notes: updates.notes ?? null
48038
+ };
48039
+ const payload = await encryptPersonCipher(fields, crypto3.key);
48040
+ const data2 = await client.post("/api/persons", payload);
48041
+ return { content: [{ type: "text", text: JSON.stringify(data2) }] };
48042
+ }
48043
+ const data = id ? await client.patch(`/api/persons/${id}`, updates) : await client.post("/api/persons", updates);
48044
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
48045
+ });
48046
+ server.registerTool("list_persons", {
48047
+ description: "List all persons in the current household. Pass `id` to get a single person by id instead of listing all.",
48048
+ inputSchema: {
48049
+ id: exports_external.string().optional().describe("Person UUID to fetch a single person; omit to list all")
48050
+ }
48051
+ }, async ({ id }) => {
48052
+ if (id) {
48053
+ const data2 = await client.get(`/api/persons/${id}`);
48054
+ if (crypto3 && data2.data.cipher) {
48055
+ const fields = await decryptPersonCipher(data2.data.cipher, crypto3.key);
48056
+ Object.assign(data2.data, fields, { cipher: null });
48057
+ }
48058
+ return { content: [{ type: "text", text: JSON.stringify(data2) }] };
48059
+ }
48060
+ const data = await client.get("/api/persons");
48061
+ if (crypto3 && data.data) {
48062
+ for (const person of data.data) {
48063
+ const cipher = person.cipher;
48064
+ if (cipher) {
48065
+ try {
48066
+ const fields = await decryptPersonCipher(cipher, crypto3.key);
48067
+ Object.assign(person, fields, { cipher: null });
48068
+ } catch {}
48069
+ }
48070
+ }
48071
+ }
48072
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
48073
+ });
48074
+ server.registerTool("delete_person", {
48075
+ description: "Delete a person. Returns 409 if any transactions still reference this person.",
48076
+ inputSchema: { id: exports_external.string().describe("Person UUID") }
48077
+ }, async ({ id }) => {
48078
+ const data = await client.delete(`/api/persons/${id}`);
48079
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
48080
+ });
48081
+ }
48082
+
48083
+ // src/tools/profile.ts
48084
+ function registerProfileTools(server, client) {
48085
+ server.registerTool("get_profile", {
48086
+ description: "Get the current user's profile (name, email, owner).",
48087
+ inputSchema: {}
48088
+ }, async () => {
48089
+ const data = await client.get("/api/profile");
48090
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
48091
+ });
48092
+ server.registerTool("update_profile", {
48093
+ description: "Update your display name.",
48094
+ inputSchema: {
48095
+ name: exports_external.string().min(1).describe("Display name")
48096
+ }
48097
+ }, async (body) => {
48098
+ const data = await client.patch("/api/profile", body);
48099
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
48100
+ });
45647
48101
  }
45648
- function resolvePersonByName(persons, query) {
45649
- const q2 = normalizePersonName(query);
45650
- if (!q2)
45651
- return null;
45652
- const exact = persons.filter((p2) => normalizePersonName(p2.name) === q2);
45653
- if (exact.length === 1)
45654
- return { personId: exact[0].id };
45655
- if (exact.length > 1)
45656
- return { candidates: exact.map((p2) => p2.name) };
45657
- const partial2 = persons.filter((p2) => normalizePersonName(p2.name).includes(q2));
45658
- if (partial2.length === 1)
45659
- return { personId: partial2[0].id };
45660
- if (partial2.length > 1)
45661
- return { candidates: partial2.map((p2) => p2.name) };
45662
- 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
+ });
45663
48119
  }
45664
- function parseNarration(description) {
45665
- const hdfc = parseHdfcNarration(description);
45666
- if (hdfc.name && hdfc.paymentMethod)
45667
- return { name: hdfc.name, upiId: hdfc.upiId, paymentMethod: hdfc.paymentMethod };
45668
- const sbi = parseSbiNarration(description);
45669
- if (sbi.name && sbi.paymentMethod)
45670
- return { name: sbi.name, upiId: sbi.upiId, paymentMethod: sbi.paymentMethod };
45671
- return {
45672
- name: hdfc.name || sbi.name,
45673
- upiId: hdfc.upiId ?? sbi.upiId,
45674
- paymentMethod: hdfc.paymentMethod ?? sbi.paymentMethod
45675
- };
48120
+
48121
+ // src/tools/recurring.ts
48122
+ function registerRecurringTools(server, client) {
48123
+ server.registerTool("list_recurring", {
48124
+ description: "List recurring transactions (subscriptions, rent, salaries). These are auto-detected from transaction history; they cannot be added manually.",
48125
+ inputSchema: {}
48126
+ }, async () => {
48127
+ const data = await client.get("/api/recurring");
48128
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
48129
+ });
48130
+ server.registerTool("sync_recurring", {
48131
+ description: "Auto-flag detected recurring merchants (isRecurring) from transaction history. Run after importing statements.",
48132
+ inputSchema: {}
48133
+ }, async () => {
48134
+ const data = await client.post("/api/recurring/sync");
48135
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
48136
+ });
45676
48137
  }
45677
- async function backfillPersons(client, crypto3, opts = {}) {
45678
- const existing = await loadDecryptedPersons(client, crypto3);
45679
- const personIdByNorm = new Map;
45680
- for (const p2 of existing)
45681
- personIdByNorm.set(normalizePersonName(p2.name), p2.id);
45682
- const candidates = [];
45683
- let cursor;
45684
- const MAX_PAGES = 100;
45685
- for (let page = 0;page < MAX_PAGES; page++) {
45686
- const params = { limit: 200 };
45687
- if (cursor)
45688
- params.cursor = cursor;
45689
- const res = await client.get("/api/transactions", params);
45690
- const rows = res.data ?? [];
45691
- if (crypto3)
45692
- await decryptTransactionFields(rows, crypto3.key);
45693
- for (const r2 of rows) {
45694
- if (!r2.merchantId && !r2.personId && !r2.isTransfer && !r2.isIgnored)
45695
- candidates.push(r2);
45696
- }
45697
- if (!res.hasMore || !res.nextCursor || rows.length === 0)
45698
- break;
45699
- cursor = res.nextCursor;
45700
- }
45701
- const groups = new Map;
45702
- for (const t2 of candidates) {
45703
- const { name, upiId, paymentMethod } = parseNarration(t2.description);
45704
- if (!name)
45705
- continue;
45706
- if (classifyEntity({ paymentMethod, extractedName: name }) !== "person")
45707
- continue;
45708
- const norm = normalizePersonName(name);
45709
- if (!norm)
45710
- continue;
45711
- const g2 = groups.get(norm) ?? {
45712
- displayName: name.trim(),
45713
- upiHandles: new Set,
45714
- txnIds: []
45715
- };
45716
- if (upiId)
45717
- g2.upiHandles.add(upiId);
45718
- g2.txnIds.push(t2.id);
45719
- groups.set(norm, g2);
45720
- }
45721
- const plan = [];
45722
- const newGroups = [];
45723
- let reused = 0;
45724
- for (const [norm, g2] of groups) {
45725
- const upiHandles = [...g2.upiHandles];
45726
- const isReuse = personIdByNorm.has(norm);
45727
- plan.push({
45728
- name: g2.displayName,
45729
- normalized: norm,
45730
- txnCount: g2.txnIds.length,
45731
- upiHandles,
45732
- action: isReuse ? "reuse" : "create"
45733
- });
45734
- if (isReuse)
45735
- reused++;
45736
- else
45737
- newGroups.push({ norm, displayName: g2.displayName, upiHandles });
45738
- }
45739
- if (!opts.dryRun && newGroups.length > 0) {
45740
- const bodies = await Promise.all(newGroups.map(async ({ displayName, upiHandles }) => {
45741
- const fields = {
45742
- name: displayName,
45743
- upiHandles,
45744
- phoneNumbers: [],
45745
- relationship: null,
45746
- notes: null
45747
- };
45748
- return crypto3 ? await encryptPersonCipher(fields, crypto3.key) : { name: displayName, upiHandles, phoneNumbers: [], relationship: null, notes: null };
45749
- }));
45750
- const res = await client.post("/api/persons/bulk", {
45751
- persons: bodies
45752
- });
45753
- res.data.forEach((p2, i2) => {
45754
- personIdByNorm.set(newGroups[i2].norm, p2.id);
45755
- });
45756
- }
45757
- const created = newGroups.length;
45758
- const links = [];
45759
- if (!opts.dryRun) {
45760
- for (const [norm, g2] of groups) {
45761
- const personId = personIdByNorm.get(norm);
45762
- if (!personId)
45763
- continue;
45764
- for (const txnId of g2.txnIds)
45765
- links.push({ transactionId: txnId, personId });
48138
+
48139
+ // src/tools/reports.ts
48140
+ function registerReportTools(server, client) {
48141
+ server.registerTool("generate_monthly_report", {
48142
+ description: "Generate a detailed monthly financial report. Computes income, expenses, savings rate, top categories, and insights. Persists to DB.",
48143
+ inputSchema: {
48144
+ month: exports_external.number().min(1).max(12),
48145
+ year: exports_external.number().min(2020).max(2100)
45766
48146
  }
45767
- }
45768
- if (!opts.dryRun && links.length > 0) {
45769
- const CHUNK = 25;
45770
- for (let i2 = 0;i2 < links.length; i2 += CHUNK) {
45771
- 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)")
45772
48155
  }
45773
- }
45774
- 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
+ });
45775
48160
  }
45776
48161
 
48162
+ // src/lib/statement-import.ts
48163
+ import { readFile as readFile2 } from "node:fs/promises";
48164
+ import { basename } from "node:path";
48165
+
45777
48166
  // src/tools/transactions.ts
48167
+ import { readFile } from "node:fs/promises";
45778
48168
  function dedupeKey(date4, description, amount, type) {
45779
48169
  return `${date4}|${description}|${amount.toFixed(2)}|${type}`;
45780
48170
  }
@@ -45788,31 +48178,23 @@ function flagBatchDuplicates(txns) {
45788
48178
  return false;
45789
48179
  });
45790
48180
  }
48181
+ var fileRowsSchema = exports_external.array(exports_external.object({
48182
+ date: exports_external.string().regex(/^\d{4}-\d{2}-\d{2}$/, "must be YYYY-MM-DD"),
48183
+ amount: exports_external.number().positive(),
48184
+ type: exports_external.enum(["debit", "credit"]),
48185
+ description: exports_external.string(),
48186
+ referenceNumber: exports_external.string().optional()
48187
+ }));
45791
48188
  async function importTransactions(client, crypto3, opts) {
45792
48189
  let txns = opts.txns;
45793
48190
  let duplicatesSkipped = 0;
45794
48191
  if (txns.length > 0) {
45795
48192
  const dates = txns.map((t2) => t2.date).sort();
45796
- const existing = [];
45797
- let cursor;
45798
- for (let page = 0;page < 100; page++) {
45799
- const params = {
45800
- limit: 200,
45801
- startDate: dates[0],
45802
- endDate: dates[dates.length - 1]
45803
- };
45804
- if (cursor)
45805
- params.cursor = cursor;
45806
- const res = await client.get("/api/transactions", params);
45807
- const batch = res.data ?? [];
45808
- if (crypto3)
45809
- await decryptTransactionFields(batch, crypto3.key);
45810
- existing.push(...batch);
45811
- if (!res.hasMore || !res.nextCursor || batch.length === 0)
45812
- break;
45813
- cursor = res.nextCursor;
45814
- }
45815
- const seen = new Set(existing.map((r2) => dedupeKey(String(r2.date), String(r2.description ?? ""), Number(r2.amount), String(r2.type))));
48193
+ const existing = await fetchAllRows(client, crypto3, {
48194
+ startDate: dates[0],
48195
+ endDate: dates[dates.length - 1]
48196
+ });
48197
+ const seen = new Set(existing.map((r2) => dedupeKey(r2.date, r2.description, r2.amount, r2.type)));
45816
48198
  const before = txns.length;
45817
48199
  txns = txns.filter((t2) => !seen.has(dedupeKey(t2.date, t2.description ?? "", t2.amount, t2.type)));
45818
48200
  duplicatesSkipped = before - txns.length;
@@ -45840,16 +48222,9 @@ async function importTransactions(client, crypto3, opts) {
45840
48222
  return { imported, duplicatesSkipped, duplicatesFlagged };
45841
48223
  }
45842
48224
  async function runRescan(client, crypto3) {
45843
- const EMPTY_BAG2 = {
45844
- merchantAliases: [],
45845
- personAliases: [],
45846
- merchantRules: {},
45847
- personRules: {}
45848
- };
45849
- let bag = EMPTY_BAG2;
48225
+ let bag = structuredClone(EMPTY_BAG);
45850
48226
  try {
45851
- const learningRes = await client.get("/api/household-learning");
45852
- bag = learningRes.data?.bag ?? EMPTY_BAG2;
48227
+ bag = (await loadBag(client)).bag;
45853
48228
  } catch {}
45854
48229
  const merchantsRes = await client.get("/api/merchants");
45855
48230
  const allMerchants = merchantsRes.data ?? [];
@@ -45859,23 +48234,8 @@ async function runRescan(client, crypto3) {
45859
48234
  const localMerchants = allMerchants.filter((m2) => m2.householdId && m2.cleanName).map((m2) => ({ id: m2.id, name: m2.cleanName }));
45860
48235
  const merchantCategoryById = new Map(allMerchants.filter((m2) => m2.categoryId).map((m2) => [m2.id, m2.categoryId]));
45861
48236
  const persons = await loadDecryptedPersons(client, crypto3);
45862
- const allRows = [];
45863
- const limit = 200;
45864
- let cursor;
45865
- const MAX_PAGES = 100;
45866
- for (let page = 0;page < MAX_PAGES; page++) {
45867
- const params = { limit };
45868
- if (cursor)
45869
- params.cursor = cursor;
45870
- const res = await client.get("/api/transactions", params);
45871
- const rows = res.data ?? [];
45872
- if (crypto3)
45873
- await decryptTransactionFields(rows, crypto3.key);
45874
- allRows.push(...rows);
45875
- if (!res.hasMore || !res.nextCursor || rows.length === 0)
45876
- break;
45877
- cursor = res.nextCursor;
45878
- }
48237
+ const allRows = await fetchAllRows(client, crypto3, { unresolved: "true" });
48238
+ const categoryByTxn = new Map(allRows.map((r2) => [r2.id, r2.categoryId]));
45879
48239
  const plan = autoCategorizeFromDescriptions({
45880
48240
  rows: allRows,
45881
48241
  bag,
@@ -45886,25 +48246,23 @@ async function runRescan(client, crypto3) {
45886
48246
  let categorized = 0;
45887
48247
  const links = plan.suggestions.flatMap((s2) => {
45888
48248
  const categoryId = s2.categoryId ?? (s2.entity.type === "merchant" ? merchantCategoryById.get(s2.entity.merchantId) ?? null : null);
45889
- if (categoryId)
45890
- categorized += s2.transactionIds.length;
45891
- return s2.transactionIds.map((txId) => ({
45892
- transactionId: txId,
45893
- ...s2.entity.type === "merchant" ? { merchantId: s2.entity.merchantId } : { personId: s2.entity.personId },
45894
- ...categoryId ? { categoryId } : {}
45895
- }));
48249
+ return s2.transactionIds.map((txId) => {
48250
+ const applyCategory = categoryId && !categoryByTxn.get(txId);
48251
+ if (applyCategory)
48252
+ categorized += 1;
48253
+ return {
48254
+ transactionId: txId,
48255
+ ...s2.entity.type === "merchant" ? { merchantId: s2.entity.merchantId } : { personId: s2.entity.personId },
48256
+ ...applyCategory ? { categoryId } : {}
48257
+ };
48258
+ });
45896
48259
  });
45897
- if (links.length > 0) {
45898
- const CHUNK = 500;
45899
- for (let i2 = 0;i2 < links.length; i2 += CHUNK) {
45900
- await client.post("/api/transactions/bulk-link-entity", { links: links.slice(i2, i2 + CHUNK) });
45901
- }
45902
- }
48260
+ await postLinks(client, links);
45903
48261
  return { linked: links.length, categorized, unresolved: plan.unresolvedCount };
45904
48262
  }
45905
48263
  function registerTransactionTools(server, client, crypto3) {
45906
48264
  server.registerTool("get_transactions", {
45907
- description: "Search and filter transactions. Returns paginated results with merchant and category details. To find everything paid to a specific counterparty, pass `personName` (resolved to a person via the household's persons list) or `personId` directly — this is an indexed lookup, not a description scan. Note: free-text `search` only matches plaintext merchant names, not encrypted descriptions.",
48265
+ description: "Search/filter transactions. Returns COMPACT rows {id,date,amt,t:'dr'|'cr',desc,cat,merchant,person,flags} " + "(default 25, max 100) plus `more`/`cursor` for the next page and, on page 1, `spent`/`income` totals over the whole filter. " + "To find everything paid to a counterparty pass `personName` or `personId` (indexed). " + "Free-text `search` only matches plaintext merchant names, not encrypted descriptions. " + "Pass `full: true` for the raw joined rows (much larger — avoid unless needed).",
45908
48266
  inputSchema: {
45909
48267
  search: exports_external.string().optional().describe("Search merchant name (NOT encrypted descriptions)"),
45910
48268
  personId: exports_external.string().optional().describe("Filter to one person (exact, indexed)"),
@@ -45914,69 +48272,126 @@ function registerTransactionTools(server, client, crypto3) {
45914
48272
  owner: exports_external.string().optional(),
45915
48273
  categorySlug: exports_external.string().optional(),
45916
48274
  categoryType: exports_external.enum(["needs", "wants", "investments", "income", "transfer"]).optional(),
45917
- source: exports_external.string().optional(),
45918
48275
  type: exports_external.enum(["debit", "credit"]).optional(),
48276
+ source: exports_external.enum([
48277
+ "bank_statement",
48278
+ "cc_statement",
48279
+ "zomato",
48280
+ "swiggy",
48281
+ "blinkit",
48282
+ "amazon",
48283
+ "zerodha_kite",
48284
+ "zerodha_coin",
48285
+ "groww",
48286
+ "cas_statement",
48287
+ "manual",
48288
+ "telegram",
48289
+ "wisprflow",
48290
+ "ai_import"
48291
+ ]).optional().describe("Filter by ingestion source"),
45919
48292
  amountMin: exports_external.number().optional(),
45920
48293
  amountMax: exports_external.number().optional(),
45921
48294
  uncategorized: exports_external.boolean().optional().describe("Only transactions with no category (server-side filter)"),
45922
- limit: exports_external.number().optional().describe("Max results (default 50, max 200)"),
45923
- cursor: exports_external.string().optional().describe("Opaque cursor from a prior page's nextCursor")
48295
+ limit: exports_external.number().optional().describe("Max results (default 25, max 100)"),
48296
+ cursor: exports_external.string().optional().describe("Opaque cursor from a prior page's `cursor`"),
48297
+ full: exports_external.boolean().optional().describe("Return raw joined rows instead of compact rows")
45924
48298
  }
45925
48299
  }, async (params) => {
45926
- const { personName, categorySlug, ...rest } = params;
48300
+ const { personName, categorySlug, full, limit, ...rest } = params;
45927
48301
  const apiParams = { ...rest };
45928
48302
  if (apiParams.uncategorized !== true)
45929
48303
  delete apiParams.uncategorized;
45930
48304
  if (categorySlug) {
45931
- const cats = await client.get("/api/categories");
45932
- const all2 = cats.data.flatMap((c2) => [c2, ...c2.children ?? []]);
45933
- const match = all2.find((c2) => c2.slug === categorySlug);
45934
- if (match)
45935
- apiParams.categoryId = match.id;
48305
+ apiParams.categoryId = requireCategory(await loadCategoryIndex(client), categorySlug).id;
45936
48306
  }
45937
48307
  if (personName && !apiParams.personId) {
45938
48308
  const persons = await loadDecryptedPersons(client, crypto3);
45939
48309
  const match = resolvePersonByName(persons, personName);
48310
+ const err = (error2) => ({
48311
+ content: [{ type: "text", text: JSON.stringify({ error: error2 }) }]
48312
+ });
45940
48313
  if (!match) {
45941
- return {
45942
- content: [
45943
- {
45944
- type: "text",
45945
- text: JSON.stringify({
45946
- error: `No person matches "${personName}". Use list_persons to see known counterparties, or run backfill_persons if persons haven't been extracted yet.`
45947
- })
45948
- }
45949
- ]
45950
- };
48314
+ return err(`No person matches "${personName}". Use list_persons to see known counterparties, or run backfill_persons if persons haven't been extracted yet.`);
45951
48315
  }
45952
48316
  if ("candidates" in match) {
45953
- return {
45954
- content: [
45955
- {
45956
- type: "text",
45957
- text: JSON.stringify({
45958
- error: `Ambiguous person "${personName}". Candidates: ${match.candidates.join(", ")}. Re-run with an exact name or personId.`
45959
- })
45960
- }
45961
- ]
45962
- };
48317
+ return err(`Ambiguous person "${personName}". Candidates: ${match.candidates.join(", ")}. Re-run with an exact name or personId.`);
45963
48318
  }
45964
48319
  apiParams.personId = match.personId;
45965
48320
  }
48321
+ apiParams.limit = Math.min(Math.max(limit ?? 25, 1), full ? 200 : 100);
48322
+ if (!full)
48323
+ apiParams.view = "lean";
45966
48324
  const data = await client.get("/api/transactions", Object.fromEntries(Object.entries(apiParams).filter(([, v2]) => v2 !== undefined).map(([k2, v2]) => [k2, String(v2)])));
45967
- if (crypto3 && data.data) {
45968
- await decryptTransactionFields(data.data, crypto3.key);
45969
- if (data.data.some((r2) => r2.personId)) {
45970
- const persons = await loadDecryptedPersons(client, crypto3);
45971
- const nameById = new Map(persons.map((p2) => [p2.id, p2.name]));
45972
- for (const r2 of data.data) {
45973
- const person = r2.person;
45974
- if (person && nameById.has(person.id))
45975
- person.name = nameById.get(person.id);
45976
- }
45977
- }
45978
- }
45979
- return { content: [{ type: "text", text: JSON.stringify(data) }] };
48325
+ const rows = data.data ?? [];
48326
+ if (crypto3)
48327
+ await decryptTransactionFields(rows, crypto3.key);
48328
+ const personNames = new Map;
48329
+ if (rows.some((r2) => r2.personId)) {
48330
+ for (const p2 of await loadDecryptedPersons(client, crypto3))
48331
+ personNames.set(p2.id, p2.name);
48332
+ }
48333
+ if (full) {
48334
+ for (const r2 of rows) {
48335
+ const person = r2.person;
48336
+ if (person && personNames.has(person.id))
48337
+ person.name = personNames.get(person.id) ?? "";
48338
+ }
48339
+ return { content: [{ type: "text", text: JSON.stringify(data) }] };
48340
+ }
48341
+ const out = {
48342
+ rows: rows.map((r2) => toLeanRow(r2, personNames)),
48343
+ ...data.hasMore ? { more: true, cursor: data.nextCursor } : {},
48344
+ ...data.summary ? { spent: data.summary.spent, income: data.summary.income } : {}
48345
+ };
48346
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
48347
+ });
48348
+ server.registerTool("get_uncategorized_groups", {
48349
+ description: "Token-cheap way to categorize: groups still-uncategorized transactions by merchant-like pattern " + "(descriptions are decrypted locally in private mode) and returns only " + "{pattern,t:'dr'|'cr',n,total,sample} per group, biggest ₹ first — never individual rows. " + "Then call `categorize_groups` with a category per pattern. Skip tiny groups via `minTotal`.",
48350
+ inputSchema: {
48351
+ startDate: exports_external.string().optional().describe("YYYY-MM-DD"),
48352
+ endDate: exports_external.string().optional().describe("YYYY-MM-DD"),
48353
+ minTotal: exports_external.number().optional().describe("Hide groups whose total ₹ is below this"),
48354
+ limit: exports_external.number().optional().describe("Max groups (default 40, max 100)")
48355
+ }
48356
+ }, async ({ startDate, endDate, minTotal, limit }) => {
48357
+ const rows = await fetchUncategorized(client, crypto3, { startDate, endDate });
48358
+ const all2 = groupRows(rows);
48359
+ const shown = all2.filter((g3) => g3.total >= (minTotal ?? 0)).slice(0, Math.min(Math.max(limit ?? 40, 1), 100));
48360
+ const shownRows = shown.reduce((n2, g3) => n2 + g3.rows.length, 0);
48361
+ const out = {
48362
+ groups: shown.map((g3) => ({
48363
+ pattern: g3.pattern,
48364
+ t: g3.type === "credit" ? "cr" : "dr",
48365
+ n: g3.rows.length,
48366
+ total: Math.round(g3.total),
48367
+ sample: g3.rows[0].description.slice(0, 50)
48368
+ })),
48369
+ rows: rows.length,
48370
+ groupsTotal: all2.length,
48371
+ rowsCovered: shownRows
48372
+ };
48373
+ return { content: [{ type: "text", text: JSON.stringify(out) }] };
48374
+ });
48375
+ server.registerTool("categorize_groups", {
48376
+ description: "Assign a category to whole groups from `get_uncategorized_groups` in ONE call. " + "For each pattern it categorizes every matching uncategorized transaction and saves a learning rule " + "(merchant + alias + category) so future imports auto-categorize it. " + "Unknown categorySlug fails before any write. Use dryRun to preview.",
48377
+ inputSchema: {
48378
+ assignments: exports_external.array(exports_external.object({
48379
+ pattern: exports_external.string().describe("Group pattern exactly as returned"),
48380
+ type: exports_external.enum(["debit", "credit"]).optional().describe("Limit to debits or credits"),
48381
+ categorySlug: exports_external.string().describe("Category slug (see list_categories)"),
48382
+ learn: exports_external.boolean().optional().describe("false = don't save a reusable rule (use for short/generic patterns)")
48383
+ })).min(1).max(100),
48384
+ startDate: exports_external.string().optional().describe("Same range used when listing groups"),
48385
+ endDate: exports_external.string().optional(),
48386
+ dryRun: exports_external.boolean().optional()
48387
+ }
48388
+ }, async ({ assignments, startDate, endDate, dryRun }) => {
48389
+ const result = await applyGroupAssignments(client, crypto3, assignments, {
48390
+ dryRun,
48391
+ startDate,
48392
+ endDate
48393
+ });
48394
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
45980
48395
  });
45981
48396
  server.registerTool("backfill_persons", {
45982
48397
  description: "Extract UPI/IMPS counterparties from unlinked transactions, create person records for them (deduped by name), and link transactions via personId — so you can later filter all payments to a person. Idempotent: re-running reuses existing persons and skips already-linked rows. Pass dryRun to preview without writing.",
@@ -46005,13 +48420,9 @@ function registerTransactionTools(server, client, crypto3) {
46005
48420
  if (crypto3 && body.notes)
46006
48421
  body.notes = await encryptField(body.notes, crypto3.key);
46007
48422
  if (body.categorySlug) {
46008
- const cats = await client.get("/api/categories");
46009
- const allCats = cats.data.flatMap((c2) => [c2, ...c2.children ?? []]);
46010
- const cat = allCats.find((c2) => c2.slug === body.categorySlug);
46011
- if (cat) {
46012
- body.categoryId = cat.id;
46013
- body.categoryType = cat.type;
46014
- }
48423
+ const cat = requireCategory(await loadCategoryIndex(client), body.categorySlug);
48424
+ body.categoryId = cat.id;
48425
+ body.categoryType = cat.type;
46015
48426
  delete body.categorySlug;
46016
48427
  }
46017
48428
  const data = await client.patch(`/api/transactions/${id}`, body);
@@ -46041,6 +48452,12 @@ function registerTransactionTools(server, client, crypto3) {
46041
48452
  resolution: exports_external.enum(["keep", "ignore"]).optional().describe("[resolve] 'ignore' excludes the duplicates, 'keep' unflags them")
46042
48453
  }
46043
48454
  }, async ({ action, transactionId, ids, resolution }) => {
48455
+ if (action === "flag" && !transactionId) {
48456
+ throw new Error("transactionId is required for action 'flag'");
48457
+ }
48458
+ if (action === "resolve" && (!ids || ids.length === 0 || !resolution)) {
48459
+ throw new Error("ids and resolution are required for action 'resolve'");
48460
+ }
46044
48461
  if (action === "flag") {
46045
48462
  const data2 = await client.patch(`/api/transactions/${transactionId}`, {
46046
48463
  isDuplicate: true,
@@ -46050,8 +48467,8 @@ function registerTransactionTools(server, client, crypto3) {
46050
48467
  }
46051
48468
  if (action === "list") {
46052
48469
  const data2 = await client.get("/api/transactions/duplicates");
46053
- if (crypto3 && data2.duplicates)
46054
- await decryptTransactionFields(data2.duplicates, crypto3.key);
48470
+ if (crypto3 && data2.data)
48471
+ await decryptTransactionFields(data2.data, crypto3.key);
46055
48472
  return { content: [{ type: "text", text: JSON.stringify(data2) }] };
46056
48473
  }
46057
48474
  const data = await client.post("/api/transactions/duplicates/resolve", {
@@ -46080,7 +48497,7 @@ function registerTransactionTools(server, client, crypto3) {
46080
48497
  source: exports_external.enum(["bank_statement", "cc_statement", "manual", "telegram", "wisprflow", "ai_import"]).describe("Origin of the statement"),
46081
48498
  granularity: exports_external.number().int().min(1).max(5).describe("Source trust level: 1=bank statement, 2=CC statement, 3=manual, 4=app export, 5=telegram/AI"),
46082
48499
  transactions: exports_external.array(exports_external.object({
46083
- date: exports_external.string().describe("YYYY-MM-DD"),
48500
+ date: exports_external.string().regex(/^\d{4}-\d{2}-\d{2}$/, "date must be YYYY-MM-DD"),
46084
48501
  amount: exports_external.number().positive(),
46085
48502
  type: exports_external.enum(["debit", "credit"]),
46086
48503
  description: exports_external.string(),
@@ -46119,7 +48536,12 @@ function registerTransactionTools(server, client, crypto3) {
46119
48536
  const parsed = JSON.parse(raw);
46120
48537
  if (!Array.isArray(parsed))
46121
48538
  throw new Error("File must contain a JSON array");
46122
- txns = parsed;
48539
+ const result = fileRowsSchema.safeParse(parsed);
48540
+ if (!result.success) {
48541
+ const i2 = result.error.issues[0];
48542
+ throw new Error(`row ${String(i2.path[0])}: ${i2.path.slice(1).join(".")} ${i2.message}`);
48543
+ }
48544
+ txns = result.data;
46123
48545
  } catch (e2) {
46124
48546
  throw new Error(`Failed to read/parse ${filePath}: ${e2 instanceof Error ? e2.message : String(e2)}`);
46125
48547
  }
@@ -46148,9 +48570,13 @@ function registerTransactionTools(server, client, crypto3) {
46148
48570
  })).min(1).max(500)
46149
48571
  }
46150
48572
  }, async ({ links }) => {
46151
- const catsRes = await client.get("/api/categories");
46152
- const allCats = catsRes.data.flatMap((c2) => [c2, ...c2.children ?? []]);
46153
- const typeById = new Map(allCats.map((c2) => [c2.id, c2.type]));
48573
+ const catIndex = await loadCategoryIndex(client);
48574
+ const typeById = new Map([...catIndex.byId].map(([id, c2]) => [id, c2.type]));
48575
+ const unknown3 = [
48576
+ ...new Set(links.map((l2) => l2.categoryId))
48577
+ ].filter((id) => !typeById.has(id));
48578
+ if (unknown3.length)
48579
+ throw new Error(`Unknown categoryId(s): ${unknown3.join(", ")}`);
46154
48580
  const groups = new Map;
46155
48581
  for (const { transactionId, categoryId } of links) {
46156
48582
  const arr = groups.get(categoryId) ?? [];
@@ -46201,11 +48627,18 @@ function registerTransactionTools(server, client, crypto3) {
46201
48627
  }).optional().describe("Filter params — deletes all matching transactions (provide one of id / ids / filters)")
46202
48628
  }
46203
48629
  }, async ({ id, ids, filters }) => {
48630
+ const given = [id, ids, filters].filter((v2) => v2 !== undefined).length;
48631
+ if (given !== 1) {
48632
+ throw new Error("Provide exactly one of id, ids, or filters");
48633
+ }
48634
+ if (filters && Object.values(filters).every((v2) => v2 === undefined)) {
48635
+ throw new Error("filters is empty — refusing to delete every transaction");
48636
+ }
46204
48637
  if (id) {
46205
48638
  const data2 = await client.delete(`/api/transactions/${id}`);
46206
48639
  return { content: [{ type: "text", text: JSON.stringify(data2) }] };
46207
48640
  }
46208
- const body = ids ? { ids } : { filters: filters ?? {} };
48641
+ const body = ids ? { ids } : { filters };
46209
48642
  const data = await client.delete("/api/transactions/bulk", body);
46210
48643
  return { content: [{ type: "text", text: JSON.stringify(data) }] };
46211
48644
  });
@@ -46229,16 +48662,152 @@ function registerTransactionTools(server, client, crypto3) {
46229
48662
  });
46230
48663
  }
46231
48664
 
48665
+ // src/lib/statement-import.ts
48666
+ var realParsers = {
48667
+ "hdfc-savings": parseHdfcSavingsPdf,
48668
+ "hdfc-card": parseHdfcCreditCardPdf,
48669
+ amex: parseAmexCreditCardPdf,
48670
+ "sbi-savings": parseSbiSavingsPdf
48671
+ };
48672
+ async function readFileAsArrayBuffer(path) {
48673
+ const b2 = await readFile2(path);
48674
+ return b2.buffer.slice(b2.byteOffset, b2.byteOffset + b2.byteLength);
48675
+ }
48676
+ function resolveAccount(accounts, query) {
48677
+ const q2 = query.trim().toLowerCase();
48678
+ const byId = accounts.find((a2) => a2.id === query);
48679
+ if (byId)
48680
+ return byId;
48681
+ let hits = accounts.filter((a2) => a2.name.toLowerCase() === q2);
48682
+ if (hits.length === 0) {
48683
+ hits = accounts.filter((a2) => a2.name.toLowerCase().includes(q2) || a2.last4 !== null && a2.last4 === q2);
48684
+ }
48685
+ if (hits.length === 1)
48686
+ return hits[0];
48687
+ const names = (hits.length ? hits : accounts).map((a2) => a2.name).join(", ");
48688
+ throw new Error(hits.length === 0 ? `No account matches "${query}". Accounts: ${names || "(none yet)"}. ` + "To add it, call upsert_bank_account (name, bankName, type: savings|current|credit_card|wallet, last4, owner), then retry." : `"${query}" matches several accounts: ${names}. Use the exact name or id.`);
48689
+ }
48690
+ function pickFormat(account, override) {
48691
+ if (override)
48692
+ return override;
48693
+ const bank = `${account.bankName ?? ""} ${account.name}`.toLowerCase();
48694
+ const card = account.type === "credit_card";
48695
+ if (bank.includes("amex") || bank.includes("american express"))
48696
+ return "amex";
48697
+ if (bank.includes("hdfc"))
48698
+ return card ? "hdfc-card" : "hdfc-savings";
48699
+ if (bank.includes("sbi") && !card)
48700
+ return "sbi-savings";
48701
+ throw new Error(`No PDF parser for "${account.name}" (${account.bankName ?? "unknown bank"}, ${account.type}) yet. ` + "Supported: HDFC savings + cards, Amex, SBI savings.");
48702
+ }
48703
+ var isPasswordError = (e2) => e2 instanceof Error && /password/i.test(`${e2.name} ${e2.message}`);
48704
+ var sum = (rows) => Math.round(rows.reduce((n2, r2) => n2 + r2.amount, 0));
48705
+ async function runStatementImport(deps, args) {
48706
+ const { client, crypto: crypto3 } = deps;
48707
+ const accounts = (await client.get("/api/accounts")).data ?? [];
48708
+ const account = resolveAccount(accounts, args.account);
48709
+ const format = pickFormat(account, args.format);
48710
+ let statement;
48711
+ try {
48712
+ statement = await deps.parsers[format](await deps.readFile(args.filePath), args.password);
48713
+ } catch (e2) {
48714
+ if (isPasswordError(e2)) {
48715
+ throw new Error(`${basename(args.filePath)} is password-protected and the password was ${args.password ? "wrong" : "not given"}.`);
48716
+ }
48717
+ throw e2;
48718
+ }
48719
+ const txns = statement.transactions.filter((t2) => t2.amount > 0);
48720
+ if (txns.length === 0) {
48721
+ throw new Error(`${basename(args.filePath)} parsed to 0 transactions (format ${format}). If it is a statement with real activity, the layout may not be supported.`);
48722
+ }
48723
+ const dates = txns.map((t2) => t2.date).sort();
48724
+ const ctx = await client.get("/api/transactions/reconciliation-context", { accountId: account.id, startDate: dates[0], endDate: dates[dates.length - 1] });
48725
+ const existing = ctx.data ?? [];
48726
+ if (crypto3)
48727
+ await decryptTransactionFields(existing, crypto3.key);
48728
+ const present = existing.map((r2) => ({ ...r2, isDuplicate: false, isIgnored: false }));
48729
+ const result = reconcile(present, { ...statement, transactions: txns });
48730
+ const fresh = result.unmatched;
48731
+ const debits = fresh.filter((t2) => t2.type === "debit");
48732
+ const credits = fresh.filter((t2) => t2.type === "credit");
48733
+ const summary = {
48734
+ account: account.name,
48735
+ file: basename(args.filePath),
48736
+ format,
48737
+ period: { from: dates[0], to: dates[dates.length - 1] },
48738
+ parsed: txns.length,
48739
+ alreadyInPaisa: result.matched.length,
48740
+ conflicts: result.conflicts.slice(0, 5).map((c2) => ({
48741
+ date: c2.incoming.date,
48742
+ amount: c2.incoming.amount,
48743
+ reason: c2.reason
48744
+ })),
48745
+ conflictCount: result.conflicts.length,
48746
+ new: {
48747
+ n: fresh.length,
48748
+ debits: { n: debits.length, total: sum(debits) },
48749
+ credits: { n: credits.length, total: sum(credits) }
48750
+ }
48751
+ };
48752
+ if (!args.confirm) {
48753
+ return {
48754
+ ...summary,
48755
+ dryRun: true,
48756
+ sample: fresh.slice(0, 8).map((t2) => ({
48757
+ date: t2.date,
48758
+ amt: t2.amount,
48759
+ t: t2.type === "credit" ? "cr" : "dr",
48760
+ desc: t2.description.slice(0, 50)
48761
+ })),
48762
+ next: fresh.length === 0 ? "Nothing new — this statement is already fully in Paisa." : `Call again with confirm:true to import the ${fresh.length} new rows${result.conflicts.length ? " (conflicts are skipped)" : ""}.`
48763
+ };
48764
+ }
48765
+ if (fresh.length === 0)
48766
+ return { ...summary, dryRun: false, imported: 0 };
48767
+ const savings = account.type !== "credit_card";
48768
+ const imported = await importTransactions(client, crypto3, {
48769
+ accountId: account.id,
48770
+ source: savings ? "bank_statement" : "cc_statement",
48771
+ granularity: savings ? 1 : 2,
48772
+ txns: fresh.map((t2) => ({
48773
+ date: t2.date,
48774
+ amount: t2.amount,
48775
+ type: t2.type,
48776
+ description: t2.description,
48777
+ referenceNumber: t2.referenceNumber ?? undefined
48778
+ }))
48779
+ });
48780
+ const rescan = await runRescan(client, crypto3);
48781
+ return { ...summary, dryRun: false, ...imported, rescan };
48782
+ }
48783
+
48784
+ // src/tools/statement-import.ts
48785
+ function registerStatementImportTools(server, client, crypto3) {
48786
+ server.registerTool("import_statement_pdf", {
48787
+ description: "Import a bank/credit-card statement PDF from a local file. TWO-PHASE: without `confirm` it only parses and reconciles against what Paisa already has and returns a summary " + "(rows already in Paisa, conflicts, NEW rows with totals) — nothing is written. Call again with confirm:true to import just the new rows, then run the categorize rescan. " + "Rows already in Paisa are never re-imported; conflicting rows (same date/name, different amount) are reported, never auto-imported. " + "Supported: HDFC savings + credit cards, Amex, SBI savings. Encrypted in private mode; reads the file locally.",
48788
+ inputSchema: {
48789
+ filePath: exports_external.string().describe("Absolute path to the statement PDF"),
48790
+ account: exports_external.string().describe("Paisa account name (e.g. 'HDFC Swiggy'), last4, or id — see list_bank_accounts. If the account doesn't exist yet, create it first with upsert_bank_account"),
48791
+ password: exports_external.string().optional().describe("PDF password, if the file is protected"),
48792
+ format: exports_external.enum(["hdfc-savings", "hdfc-card", "amex", "sbi-savings"]).optional().describe("Override the parser (default: chosen from the account's bank and type)"),
48793
+ confirm: exports_external.boolean().optional().describe("false/omitted = dry run (nothing written); true = import the new rows")
48794
+ }
48795
+ }, async (args) => {
48796
+ const result = await runStatementImport({ client, crypto: crypto3, readFile: readFileAsArrayBuffer, parsers: realParsers }, args);
48797
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
48798
+ });
48799
+ }
48800
+
46232
48801
  // src/tools/upcoming.ts
46233
48802
  function registerUpcomingTools(server, client) {
46234
48803
  server.registerTool("get_upcoming", {
46235
48804
  description: "Get upcoming payments (EMIs, SIPs, insurance renewals, CC billing) due in the next N days.",
46236
48805
  inputSchema: {
46237
- days: exports_external.number().optional().describe("Look-ahead window in days (default: 60)")
48806
+ days: exports_external.number().int().min(1).max(365).optional().describe("Look-ahead window in days (1-365, default 60)")
46238
48807
  }
46239
- }, async (params) => {
46240
- const data = await client.get("/api/upcoming", params.days ? { days: params.days } : undefined);
46241
- 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) }] };
46242
48811
  });
46243
48812
  }
46244
48813
 
@@ -46253,6 +48822,79 @@ function registerWaitlistTools(server, client) {
46253
48822
  });
46254
48823
  }
46255
48824
 
48825
+ // src/registrations.ts
48826
+ var REGISTRATIONS = [
48827
+ { title: "Transactions", register: registerTransactionTools },
48828
+ { title: "Statement import", register: registerStatementImportTools },
48829
+ { title: "Jev auto-categorize", register: registerJevTools },
48830
+ { title: "Merchants", register: registerMerchantTools },
48831
+ { title: "Persons", register: registerPersonTools },
48832
+ { title: "Categories", register: registerCategoryTools },
48833
+ { title: "Analytics", register: registerAnalyticsTools },
48834
+ { title: "Bank accounts", register: registerAccountTools },
48835
+ { title: "Goals & EMIs", register: registerGoalTools },
48836
+ { title: "Budgets", register: registerBudgetTools },
48837
+ { title: "Upcoming", register: registerUpcomingTools },
48838
+ { title: "Dashboard", register: registerDashboardTools },
48839
+ { title: "Recurring & subscriptions", register: registerRecurringTools },
48840
+ { title: "Alerts", register: registerAlertTools },
48841
+ { title: "Alert rules", register: registerAlertRuleTools },
48842
+ { title: "Debts", register: registerDebtTools },
48843
+ { title: "Reports", register: registerReportTools },
48844
+ { title: "Export", register: registerExportTools },
48845
+ { title: "Heatmap", register: registerHeatmapTools },
48846
+ { title: "Household", register: registerHouseholdTools },
48847
+ { title: "Household learning", register: registerHouseholdLearningTools },
48848
+ { title: "Net worth", register: registerNetWorthTools },
48849
+ { title: "Investments", register: registerInvestmentTools },
48850
+ { title: "Profile", register: registerProfileTools },
48851
+ { title: "Encryption & privacy", register: registerAuthCryptoTools },
48852
+ { title: "Waitlist", register: registerWaitlistTools },
48853
+ { title: "Push notifications", register: registerPushTools }
48854
+ ];
48855
+ // package.json
48856
+ var package_default = {
48857
+ name: "paisa-mcp",
48858
+ version: "0.0.22",
48859
+ description: "Paisa MCP server — personal finance tools for AI assistants",
48860
+ bin: {
48861
+ "paisa-mcp": "dist/index.js"
48862
+ },
48863
+ type: "module",
48864
+ files: [
48865
+ "dist"
48866
+ ],
48867
+ scripts: {
48868
+ build: 'bun build src/index.ts --outdir dist --target node --format esm --sourcemap --external pdfjs-dist --external "pdfjs-dist/*"',
48869
+ "gen:tools-doc": "bun src/tools-doc.ts",
48870
+ start: "node dist/index.js",
48871
+ test: "bun test",
48872
+ typecheck: "tsc --noEmit"
48873
+ },
48874
+ dependencies: {
48875
+ "pdfjs-dist": "^5.6.205"
48876
+ },
48877
+ devDependencies: {
48878
+ "@modelcontextprotocol/sdk": "^1.12.1",
48879
+ "@paisa/parsers": "workspace:*",
48880
+ "@paisa/reconciliation": "workspace:*",
48881
+ "@paisa/types": "workspace:*",
48882
+ "@scure/bip39": "^2.2.0",
48883
+ "@types/bun": "^1.3.14",
48884
+ "@typesafe-ai/sdk": "^0.6.0",
48885
+ axios: "^1.16.1",
48886
+ "hash-wasm": "^4.12.0",
48887
+ typescript: "^5.8.3",
48888
+ zod: "^3.25.17"
48889
+ },
48890
+ engines: {
48891
+ node: ">=20.0.0"
48892
+ }
48893
+ };
48894
+
48895
+ // src/version.ts
48896
+ var VERSION3 = package_default.version;
48897
+
46256
48898
  // src/index.ts
46257
48899
  async function main() {
46258
48900
  const apiUrl = process.env.PAISA_API_URL;
@@ -46263,55 +48905,16 @@ async function main() {
46263
48905
  process.exit(1);
46264
48906
  }
46265
48907
  const client = new PaisaApiClient(apiUrl, apiToken);
46266
- let cryptoCtx;
46267
- const pin = process.env.PAISA_PIN;
46268
- const userId = process.env.PAISA_USER_ID;
46269
- if (pin) {
46270
- if (!userId) {
46271
- console.error("Error: PAISA_USER_ID is required when PAISA_PIN is set.");
46272
- process.exit(1);
46273
- }
46274
- const cryptoMeta = await client.get("/api/auth-crypto").catch(() => null);
46275
- if (cryptoMeta && (cryptoMeta.kdfVersion ?? 1) >= 2 && cryptoMeta.kdfSalt && cryptoMeta.wrappedDek) {
46276
- const kek = await deriveKEK(pin, cryptoMeta.kdfSalt);
46277
- const dek = await unwrapDEK(cryptoMeta.wrappedDek, kek);
46278
- cryptoCtx = { key: dek };
46279
- } else {
46280
- const key = await deriveKey(userId, pin, userId);
46281
- cryptoCtx = { key };
46282
- }
48908
+ const cryptoCtx = await initCrypto(client, process.env.PAISA_PIN, process.env.PAISA_USER_ID);
48909
+ if (cryptoCtx)
46283
48910
  console.error("Paisa MCP: private mode — encryption active");
46284
- }
46285
48911
  const crypto3 = cryptoCtx;
46286
48912
  const server = new McpServer({
46287
48913
  name: "paisa",
46288
- version: "0.0.1"
46289
- });
46290
- registerTransactionTools(server, client, crypto3);
46291
- registerMerchantTools(server, client, crypto3);
46292
- registerPersonTools(server, client, crypto3);
46293
- registerCategoryTools(server, client);
46294
- registerAnalyticsTools(server, client, crypto3);
46295
- registerAccountTools(server, client);
46296
- registerGoalTools(server, client);
46297
- registerBudgetTools(server, client);
46298
- registerUpcomingTools(server, client);
46299
- registerDashboardTools(server, client, crypto3);
46300
- registerRecurringTools(server, client);
46301
- registerAlertTools(server, client);
46302
- registerAlertRuleTools(server, client);
46303
- registerDebtTools(server, client);
46304
- registerReportTools(server, client);
46305
- registerExportTools(server, client, crypto3);
46306
- registerHeatmapTools(server, client);
46307
- registerHouseholdTools(server, client);
46308
- registerHouseholdLearningTools(server, client, crypto3);
46309
- registerNetWorthTools(server, client);
46310
- registerInvestmentTools(server, client);
46311
- registerProfileTools(server, client);
46312
- registerAuthCryptoTools(server, client);
46313
- registerWaitlistTools(server, client);
46314
- registerPushTools(server, client);
48914
+ version: VERSION3
48915
+ });
48916
+ for (const { register } of REGISTRATIONS)
48917
+ register(server, client, crypto3);
46315
48918
  const transport = new StdioServerTransport;
46316
48919
  await server.connect(transport);
46317
48920
  console.error("Paisa MCP server running on stdio");
@@ -46321,5 +48924,5 @@ main().catch((err) => {
46321
48924
  process.exit(1);
46322
48925
  });
46323
48926
 
46324
- //# debugId=1EEBC94B29D2067F64756E2164756E21
48927
+ //# debugId=BC4AFE8852AA4C8E64756E2164756E21
46325
48928
  //# sourceMappingURL=index.js.map