ray-finance 0.2.2 → 0.2.3

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 (62) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/.github/ray-logo.png +0 -0
  3. package/.github/workflows/ci.yml +1 -0
  4. package/Dockerfile +2 -2
  5. package/README.md +31 -10
  6. package/SECURITY.md +1 -1
  7. package/dist/ai/agent.js +16 -3
  8. package/dist/ai/context.js +6 -2
  9. package/dist/ai/insights.js +26 -3
  10. package/dist/ai/redactor.js +11 -0
  11. package/dist/ai/system-prompt.js +2 -2
  12. package/dist/ai/tools.js +4 -0
  13. package/dist/cli/backup.js +18 -9
  14. package/dist/cli/chat.js +146 -40
  15. package/dist/cli/format.d.ts +2 -0
  16. package/dist/cli/format.js +25 -0
  17. package/dist/cli/index.js +12 -2
  18. package/dist/cli/setup.js +7 -1
  19. package/dist/daily-sync.js +19 -4
  20. package/dist/db/connection.js +9 -1
  21. package/dist/db/encryption.js +18 -7
  22. package/dist/db/schema.js +6 -1
  23. package/dist/public/favicon.png +0 -0
  24. package/dist/public/link.html +47 -24
  25. package/dist/public/ray-logo-dark.png +0 -0
  26. package/dist/queries/index.js +8 -8
  27. package/dist/server.js +33 -1
  28. package/package.json +4 -2
  29. package/site/package-lock.json +43 -0
  30. package/site/package.json +1 -0
  31. package/site/public/ray-logo-dark.png +0 -0
  32. package/site/public/ray-logo-light.png +0 -0
  33. package/site/src/app/copy-command.tsx +1 -3
  34. package/site/src/app/layout.tsx +2 -1
  35. package/src/ai/agent.ts +15 -3
  36. package/src/ai/context.ts +3 -2
  37. package/src/ai/insights.ts +25 -3
  38. package/src/ai/redactor.test.ts +63 -0
  39. package/src/ai/redactor.ts +12 -0
  40. package/src/ai/system-prompt.ts +2 -2
  41. package/src/ai/tools.ts +4 -0
  42. package/src/cli/backup.ts +23 -10
  43. package/src/cli/chat.ts +155 -41
  44. package/src/cli/format.ts +31 -0
  45. package/src/cli/index.ts +12 -2
  46. package/src/cli/setup.ts +6 -1
  47. package/src/daily-sync.test.ts +150 -0
  48. package/src/daily-sync.ts +19 -4
  49. package/src/db/connection.ts +12 -1
  50. package/src/db/encryption.test.ts +86 -0
  51. package/src/db/encryption.ts +17 -7
  52. package/src/db/schema.test.ts +53 -0
  53. package/src/db/schema.ts +7 -1
  54. package/src/public/favicon.png +0 -0
  55. package/src/public/link.html +47 -24
  56. package/src/public/ray-logo-dark.png +0 -0
  57. package/src/queries/index.test.ts +397 -0
  58. package/src/queries/index.ts +8 -8
  59. package/src/server.ts +37 -1
  60. package/tsconfig.json +1 -1
  61. package/vitest.config.ts +7 -0
  62. package/SPEC.md +0 -374
@@ -0,0 +1,397 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import Database from "better-sqlite3-multiple-ciphers";
3
+ import { migrate } from "../db/schema.js";
4
+ import {
5
+ formatMoney,
6
+ categoryLabel,
7
+ getNetWorth,
8
+ getAccountBalances,
9
+ getBudgetStatuses,
10
+ getGoals,
11
+ getCashFlowThisMonth,
12
+ getTransactionsFiltered,
13
+ searchTransactions,
14
+ compareSpending,
15
+ getNetWorthTrend,
16
+ forecastBalance,
17
+ getPortfolio,
18
+ getInvestmentPerformance,
19
+ getDebts,
20
+ getCashFlow,
21
+ getIncome,
22
+ } from "./index.js";
23
+
24
+ type DB = InstanceType<typeof Database>;
25
+
26
+ function createTestDb(): DB {
27
+ const db = new Database(":memory:");
28
+ db.pragma("foreign_keys = ON");
29
+ migrate(db);
30
+ return db;
31
+ }
32
+
33
+ // Seed helpers
34
+ function seedInstitution(db: DB, id = "inst-1") {
35
+ db.prepare(`INSERT OR IGNORE INTO institutions (item_id, access_token, name) VALUES (?, 'tok', ?)`).run(id, "Test Bank");
36
+ }
37
+
38
+ function seedAccount(db: DB, opts: { id: string; type: string; balance: number; subtype?: string; itemId?: string; name?: string }) {
39
+ seedInstitution(db, opts.itemId || "inst-1");
40
+ db.prepare(
41
+ `INSERT OR REPLACE INTO accounts (account_id, item_id, name, type, subtype, current_balance) VALUES (?, ?, ?, ?, ?, ?)`
42
+ ).run(opts.id, opts.itemId || "inst-1", opts.name || opts.id, opts.type, opts.subtype || null, opts.balance);
43
+ }
44
+
45
+ function seedTransaction(db: DB, opts: { id: string; accountId: string; amount: number; date: string; name: string; category?: string; merchant?: string; pending?: number; subcategory?: string }) {
46
+ db.prepare(
47
+ `INSERT OR REPLACE INTO transactions (transaction_id, account_id, amount, date, name, merchant_name, category, subcategory, pending) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
48
+ ).run(opts.id, opts.accountId, opts.amount, opts.date, opts.name, opts.merchant || null, opts.category || "OTHER", opts.subcategory || null, opts.pending ?? 0);
49
+ }
50
+
51
+ function today(): string {
52
+ return new Date().toISOString().slice(0, 10);
53
+ }
54
+
55
+ function daysAgo(n: number): string {
56
+ return new Date(Date.now() - n * 86400000).toISOString().slice(0, 10);
57
+ }
58
+
59
+ // ─── Pure functions ───
60
+
61
+ describe("formatMoney", () => {
62
+ it("formats positive", () => expect(formatMoney(1234.5)).toBe("$1,234.50"));
63
+ it("formats negative as absolute", () => expect(formatMoney(-99)).toBe("$99.00"));
64
+ it("formats zero", () => expect(formatMoney(0)).toBe("$0.00"));
65
+ });
66
+
67
+ describe("categoryLabel", () => {
68
+ it("maps known categories", () => {
69
+ expect(categoryLabel("FOOD_AND_DRINK")).toBe("Food & Drink");
70
+ expect(categoryLabel("ENTERTAINMENT")).toBe("Entertainment");
71
+ });
72
+ it("returns raw value for unknown", () => {
73
+ expect(categoryLabel("CUSTOM_CAT")).toBe("CUSTOM_CAT");
74
+ });
75
+ });
76
+
77
+ // ─── Database query functions ───
78
+
79
+ let db: DB;
80
+
81
+ beforeEach(() => {
82
+ db = createTestDb();
83
+ });
84
+
85
+ describe("getNetWorth", () => {
86
+ it("returns zeros with no accounts", () => {
87
+ const nw = getNetWorth(db);
88
+ expect(nw.net_worth).toBe(0);
89
+ expect(nw.assets).toBe(0);
90
+ expect(nw.liabilities).toBe(0);
91
+ });
92
+
93
+ it("computes assets - liabilities", () => {
94
+ seedAccount(db, { id: "checking", type: "depository", balance: 5000 });
95
+ seedAccount(db, { id: "invest", type: "investment", balance: 10000 });
96
+ seedAccount(db, { id: "cc", type: "credit", balance: 2000 });
97
+
98
+ const nw = getNetWorth(db);
99
+ expect(nw.assets).toBe(15000);
100
+ expect(nw.liabilities).toBe(2000);
101
+ expect(nw.net_worth).toBe(13000);
102
+ expect(nw.cash).toBe(5000);
103
+ expect(nw.investments).toBe(10000);
104
+ expect(nw.credit_debt).toBe(2000);
105
+ });
106
+
107
+ it("computes home equity", () => {
108
+ seedAccount(db, { id: "home", type: "other", subtype: "property", balance: 400000 });
109
+ seedAccount(db, { id: "mort", type: "loan", subtype: "mortgage", balance: 300000 });
110
+
111
+ const nw = getNetWorth(db);
112
+ expect(nw.home_value).toBe(400000);
113
+ expect(nw.home_equity).toBe(100000);
114
+ expect(nw.mortgage).toBe(300000);
115
+ });
116
+
117
+ it("returns prev_net_worth from history", () => {
118
+ seedAccount(db, { id: "a", type: "depository", balance: 1000 });
119
+ db.prepare(`INSERT INTO net_worth_history (date, total_assets, total_liabilities, net_worth) VALUES (?, ?, ?, ?)`)
120
+ .run(daysAgo(2), 900, 0, 900);
121
+
122
+ const nw = getNetWorth(db);
123
+ expect(nw.prev_net_worth).toBe(900);
124
+ });
125
+
126
+ it("returns null prev_net_worth when no history", () => {
127
+ const nw = getNetWorth(db);
128
+ expect(nw.prev_net_worth).toBeNull();
129
+ });
130
+ });
131
+
132
+ describe("getAccountBalances", () => {
133
+ it("returns depository and credit accounts sorted", () => {
134
+ seedAccount(db, { id: "a", type: "credit", balance: 500, name: "CC" });
135
+ seedAccount(db, { id: "b", type: "depository", balance: 3000, name: "Checking" });
136
+ seedAccount(db, { id: "c", type: "depository", balance: 1000, name: "Savings" });
137
+ seedAccount(db, { id: "d", type: "investment", balance: 9999, name: "Brokerage" });
138
+
139
+ const balances = getAccountBalances(db);
140
+ // investment excluded, credit and depository included
141
+ expect(balances.map((b) => b.name)).not.toContain("Brokerage");
142
+ expect(balances.length).toBe(3);
143
+ // depository sorted by balance desc, then credit
144
+ expect(balances[0].name).toBe("CC");
145
+ expect(balances[1].name).toBe("Checking");
146
+ expect(balances[2].name).toBe("Savings");
147
+ });
148
+ });
149
+
150
+ describe("getBudgetStatuses", () => {
151
+ it("tracks spending against budget", () => {
152
+ seedAccount(db, { id: "a", type: "depository", balance: 1000 });
153
+ db.prepare(`INSERT INTO budgets (category, monthly_limit) VALUES (?, ?)`).run("FOOD_AND_DRINK", 500);
154
+
155
+ // Spend $300 this month
156
+ seedTransaction(db, { id: "t1", accountId: "a", amount: 200, date: today(), name: "Restaurant", category: "FOOD_AND_DRINK" });
157
+ seedTransaction(db, { id: "t2", accountId: "a", amount: 100, date: today(), name: "Groceries", category: "FOOD_AND_DRINK" });
158
+
159
+ const [budget] = getBudgetStatuses(db);
160
+ expect(budget.category).toBe("FOOD_AND_DRINK");
161
+ expect(budget.budget).toBe(500);
162
+ expect(budget.spent).toBe(300);
163
+ expect(budget.remaining).toBe(200);
164
+ expect(budget.pct_used).toBe(60);
165
+ expect(budget.over_budget).toBe(false);
166
+ });
167
+
168
+ it("flags over budget", () => {
169
+ seedAccount(db, { id: "a", type: "depository", balance: 1000 });
170
+ db.prepare(`INSERT INTO budgets (category, monthly_limit) VALUES (?, ?)`).run("ENTERTAINMENT", 100);
171
+ seedTransaction(db, { id: "t1", accountId: "a", amount: 150, date: today(), name: "Concert", category: "ENTERTAINMENT" });
172
+
173
+ const [budget] = getBudgetStatuses(db);
174
+ expect(budget.over_budget).toBe(true);
175
+ expect(budget.remaining).toBe(-50);
176
+ });
177
+ });
178
+
179
+ describe("getGoals", () => {
180
+ it("computes progress and monthly needed", () => {
181
+ const futureDate = new Date();
182
+ futureDate.setMonth(futureDate.getMonth() + 6);
183
+ const targetDate = futureDate.toISOString().slice(0, 10);
184
+
185
+ db.prepare(`INSERT INTO goals (name, target_amount, current_amount, target_date) VALUES (?, ?, ?, ?)`)
186
+ .run("Emergency Fund", 10000, 4000, targetDate);
187
+
188
+ const [goal] = getGoals(db);
189
+ expect(goal.name).toBe("Emergency Fund");
190
+ expect(goal.target).toBe(10000);
191
+ expect(goal.current).toBe(4000);
192
+ expect(goal.remaining).toBe(6000);
193
+ expect(goal.progress_pct).toBe(40);
194
+ expect(goal.monthly_needed).toBe(1000);
195
+ });
196
+ });
197
+
198
+ describe("getCashFlowThisMonth", () => {
199
+ it("separates income and expenses", () => {
200
+ seedAccount(db, { id: "a", type: "depository", balance: 5000 });
201
+
202
+ // Income: negative amounts in Plaid convention
203
+ seedTransaction(db, { id: "t1", accountId: "a", amount: -3000, date: today(), name: "Paycheck", category: "INCOME" });
204
+ // Expense: positive amounts
205
+ seedTransaction(db, { id: "t2", accountId: "a", amount: 50, date: today(), name: "Coffee", category: "FOOD_AND_DRINK" });
206
+ seedTransaction(db, { id: "t3", accountId: "a", amount: 200, date: today(), name: "Gas", category: "TRANSPORTATION" });
207
+
208
+ const cf = getCashFlowThisMonth(db);
209
+ expect(cf.income).toBe(3000);
210
+ expect(cf.expenses).toBe(250);
211
+ expect(cf.net).toBe(2750);
212
+ });
213
+
214
+ it("excludes transfers", () => {
215
+ seedAccount(db, { id: "a", type: "depository", balance: 5000 });
216
+ seedTransaction(db, { id: "t1", accountId: "a", amount: -500, date: today(), name: "Transfer", category: "TRANSFER_IN" });
217
+ seedTransaction(db, { id: "t2", accountId: "a", amount: 500, date: today(), name: "Transfer", category: "TRANSFER_OUT" });
218
+
219
+ const cf = getCashFlowThisMonth(db);
220
+ expect(cf.income).toBe(0);
221
+ expect(cf.expenses).toBe(0);
222
+ });
223
+ });
224
+
225
+ describe("getTransactionsFiltered", () => {
226
+ beforeEach(() => {
227
+ seedAccount(db, { id: "a", type: "depository", balance: 1000 });
228
+ seedTransaction(db, { id: "t1", accountId: "a", amount: 50, date: "2025-01-15", name: "Coffee Shop", merchant: "Starbucks", category: "FOOD_AND_DRINK" });
229
+ seedTransaction(db, { id: "t2", accountId: "a", amount: 200, date: "2025-02-10", name: "Electronics", merchant: "Best Buy", category: "GENERAL_MERCHANDISE" });
230
+ seedTransaction(db, { id: "t3", accountId: "a", amount: 15, date: "2025-02-20", name: "Snack", merchant: "7-Eleven", category: "FOOD_AND_DRINK" });
231
+ });
232
+
233
+ it("filters by date range", () => {
234
+ const txns = getTransactionsFiltered(db, { startDate: "2025-02-01", endDate: "2025-02-28" });
235
+ expect(txns.length).toBe(2);
236
+ });
237
+
238
+ it("filters by category", () => {
239
+ const txns = getTransactionsFiltered(db, { category: "FOOD_AND_DRINK" });
240
+ expect(txns.length).toBe(2);
241
+ });
242
+
243
+ it("filters by merchant (LIKE match)", () => {
244
+ const txns = getTransactionsFiltered(db, { merchant: "Star" });
245
+ expect(txns.length).toBe(1);
246
+ expect(txns[0].name).toBe("Coffee Shop");
247
+ });
248
+
249
+ it("filters by amount range", () => {
250
+ const txns = getTransactionsFiltered(db, { minAmount: 20, maxAmount: 100 });
251
+ expect(txns.length).toBe(1);
252
+ expect(txns[0].amount).toBe(50);
253
+ });
254
+
255
+ it("respects limit", () => {
256
+ const txns = getTransactionsFiltered(db, { limit: 1 });
257
+ expect(txns.length).toBe(1);
258
+ });
259
+ });
260
+
261
+ describe("searchTransactions", () => {
262
+ beforeEach(() => {
263
+ seedAccount(db, { id: "a", type: "depository", balance: 1000 });
264
+ seedTransaction(db, { id: "t1", accountId: "a", amount: 50, date: "2025-01-15", name: "Uber Eats", category: "FOOD_AND_DRINK" });
265
+ seedTransaction(db, { id: "t2", accountId: "a", amount: 30, date: "2025-01-16", name: "Uber Ride", category: "TRANSPORTATION" });
266
+ });
267
+
268
+ it("matches on name", () => {
269
+ const results = searchTransactions(db, "Uber");
270
+ expect(results.length).toBe(2);
271
+ });
272
+
273
+ it("matches on category", () => {
274
+ const results = searchTransactions(db, "TRANSPORT");
275
+ expect(results.length).toBe(1);
276
+ });
277
+ });
278
+
279
+ describe("compareSpending", () => {
280
+ it("computes period differences by category", () => {
281
+ seedAccount(db, { id: "a", type: "depository", balance: 5000 });
282
+ // Period 1: Jan
283
+ seedTransaction(db, { id: "t1", accountId: "a", amount: 200, date: "2025-01-15", name: "Food", category: "FOOD_AND_DRINK" });
284
+ // Period 2: Feb - more food spending
285
+ seedTransaction(db, { id: "t2", accountId: "a", amount: 350, date: "2025-02-15", name: "Food", category: "FOOD_AND_DRINK" });
286
+
287
+ const result = compareSpending(db, "2025-01-01", "2025-01-31", "2025-02-01", "2025-02-28");
288
+ expect(result.period1Total).toBe(200);
289
+ expect(result.period2Total).toBe(350);
290
+ expect(result.difference).toBe(150);
291
+ expect(result.categories.length).toBe(1);
292
+ expect(result.categories[0].diff).toBe(150);
293
+ });
294
+ });
295
+
296
+ describe("getNetWorthTrend", () => {
297
+ it("returns history in chronological order", () => {
298
+ db.prepare(`INSERT INTO net_worth_history (date, total_assets, total_liabilities, net_worth) VALUES (?, ?, ?, ?)`)
299
+ .run("2025-01-01", 1000, 100, 900);
300
+ db.prepare(`INSERT INTO net_worth_history (date, total_assets, total_liabilities, net_worth) VALUES (?, ?, ?, ?)`)
301
+ .run("2025-01-02", 1100, 100, 1000);
302
+
303
+ const trend = getNetWorthTrend(db);
304
+ expect(trend.length).toBe(2);
305
+ expect(trend[0].date).toBe("2025-01-01");
306
+ expect(trend[1].date).toBe("2025-01-02");
307
+ });
308
+
309
+ it("respects limit", () => {
310
+ for (let i = 1; i <= 5; i++) {
311
+ db.prepare(`INSERT INTO net_worth_history (date, total_assets, total_liabilities, net_worth) VALUES (?, ?, ?, ?)`)
312
+ .run(`2025-01-0${i}`, 1000 * i, 0, 1000 * i);
313
+ }
314
+ expect(getNetWorthTrend(db, 3).length).toBe(3);
315
+ });
316
+ });
317
+
318
+ describe("forecastBalance", () => {
319
+ it("projects future balances", () => {
320
+ seedAccount(db, { id: "a", type: "depository", balance: 10000 });
321
+
322
+ // Seed 3 months of transactions for averaging
323
+ for (let m = 1; m <= 3; m++) {
324
+ const date = daysAgo(m * 30);
325
+ seedTransaction(db, { id: `inc-${m}`, accountId: "a", amount: -5000, date, name: "Paycheck" });
326
+ seedTransaction(db, { id: `exp-${m}`, accountId: "a", amount: 3000, date, name: "Rent" });
327
+ }
328
+
329
+ const forecast = forecastBalance(db);
330
+ expect(forecast.currentBalance).toBe(10000);
331
+ expect(forecast.projections.length).toBe(6);
332
+ // Net positive cash flow → projections should increase
333
+ expect(forecast.projections[5].projected).toBeGreaterThan(forecast.currentBalance);
334
+ });
335
+ });
336
+
337
+ describe("getPortfolio", () => {
338
+ it("returns holdings with gain/loss", () => {
339
+ seedAccount(db, { id: "brok", type: "investment", balance: 10000 });
340
+ db.prepare(`INSERT INTO securities (security_id, name, ticker) VALUES (?, ?, ?)`).run("sec-1", "Apple Inc", "AAPL");
341
+ db.prepare(`INSERT INTO holdings (account_id, security_id, quantity, value, cost_basis) VALUES (?, ?, ?, ?, ?)`)
342
+ .run("brok", "sec-1", 10, 1500, 1000);
343
+
344
+ const portfolio = getPortfolio(db);
345
+ expect(portfolio.totalValue).toBe(1500);
346
+ expect(portfolio.totalCostBasis).toBe(1000);
347
+ expect(portfolio.totalGainLoss).toBe(500);
348
+ expect(portfolio.holdings[0].ticker).toBe("AAPL");
349
+ expect(portfolio.holdings[0].gainLoss).toBe(500);
350
+ });
351
+ });
352
+
353
+ describe("getDebts", () => {
354
+ it("returns liabilities sorted by rate", () => {
355
+ seedAccount(db, { id: "cc", type: "credit", balance: 1000 });
356
+ db.prepare(`INSERT INTO liabilities (account_id, type, interest_rate, current_balance, minimum_payment) VALUES (?, ?, ?, ?, ?)`)
357
+ .run("cc", "credit", 24.99, 1000, 25);
358
+
359
+ const result = getDebts(db);
360
+ expect(result.totalDebt).toBe(1000);
361
+ expect(result.debts[0].rate).toBe(24.99);
362
+ });
363
+
364
+ it("falls back to credit accounts when no liabilities", () => {
365
+ seedAccount(db, { id: "cc", type: "credit", balance: 500 });
366
+ const result = getDebts(db);
367
+ expect(result.totalDebt).toBe(500);
368
+ });
369
+ });
370
+
371
+ describe("getCashFlow", () => {
372
+ it("computes savings rate", () => {
373
+ seedAccount(db, { id: "a", type: "depository", balance: 5000 });
374
+ seedTransaction(db, { id: "t1", accountId: "a", amount: -5000, date: "2025-01-15", name: "Salary", category: "INCOME" });
375
+ seedTransaction(db, { id: "t2", accountId: "a", amount: 2000, date: "2025-01-20", name: "Rent", category: "RENT_AND_UTILITIES" });
376
+
377
+ const cf = getCashFlow(db, "2025-01-01", "2025-01-31");
378
+ expect(cf.income).toBe(5000);
379
+ expect(cf.expenses).toBe(2000);
380
+ expect(cf.net).toBe(3000);
381
+ expect(cf.savingsRate).toBe(60);
382
+ expect(cf.monthly.length).toBe(1);
383
+ });
384
+ });
385
+
386
+ describe("getIncome", () => {
387
+ it("groups income by source", () => {
388
+ seedAccount(db, { id: "a", type: "depository", balance: 5000 });
389
+ seedTransaction(db, { id: "t1", accountId: "a", amount: -3000, date: "2025-01-10", name: "Payroll", merchant: "Acme Corp", category: "INCOME" });
390
+ seedTransaction(db, { id: "t2", accountId: "a", amount: -500, date: "2025-01-15", name: "Freelance", merchant: "Client LLC", category: "INCOME" });
391
+
392
+ const income = getIncome(db, "2025-01-01", "2025-01-31");
393
+ expect(income.length).toBe(2);
394
+ expect(income[0].total).toBe(3000); // sorted desc
395
+ expect(income[0].source).toBe("Acme Corp");
396
+ });
397
+ });
@@ -396,13 +396,13 @@ export function getPortfolio(db: Database): {
396
396
  holdings: { account: string; security: string; ticker: string; quantity: number; value: number; costBasis: number; gainLoss: number }[];
397
397
  } {
398
398
  const rows = db.prepare(
399
- `SELECT a.name as account, s.name as security, s.ticker_symbol as ticker,
400
- h.quantity, h.institution_value as value, h.cost_basis,
401
- (h.institution_value - COALESCE(h.cost_basis, h.institution_value)) as gain_loss
399
+ `SELECT a.name as account, s.name as security, s.ticker,
400
+ h.quantity, h.value, h.cost_basis,
401
+ (h.value - COALESCE(h.cost_basis, h.value)) as gain_loss
402
402
  FROM holdings h
403
403
  JOIN accounts a ON h.account_id = a.account_id
404
404
  LEFT JOIN securities s ON h.security_id = s.security_id
405
- ORDER BY h.institution_value DESC`
405
+ ORDER BY h.value DESC`
406
406
  ).all() as any[];
407
407
 
408
408
  const totalValue = rows.reduce((s: number, r: any) => s + (r.value || 0), 0);
@@ -431,12 +431,12 @@ export function getInvestmentPerformance(db: Database): {
431
431
  holdings: { security: string; ticker: string; value: number; costBasis: number; returnPct: number }[];
432
432
  } {
433
433
  const rows = db.prepare(
434
- `SELECT s.name as security, s.ticker_symbol as ticker,
435
- h.institution_value as value, h.cost_basis
434
+ `SELECT s.name as security, s.ticker,
435
+ h.value, h.cost_basis
436
436
  FROM holdings h
437
437
  LEFT JOIN securities s ON h.security_id = s.security_id
438
- WHERE h.institution_value IS NOT NULL
439
- ORDER BY h.institution_value DESC`
438
+ WHERE h.value IS NOT NULL
439
+ ORDER BY h.value DESC`
440
440
  ).all() as any[];
441
441
 
442
442
  const totalValue = rows.reduce((s: number, r: any) => s + (r.value || 0), 0);
package/src/server.ts CHANGED
@@ -16,6 +16,22 @@ const __dirname = dirname(__filename);
16
16
  // Session map for Plaid Link — maps sessionId → true (single-user, no chatId needed)
17
17
  const linkSessions = new Map<string, boolean>();
18
18
 
19
+ // Simple rate limiter: track request counts per IP
20
+ const rateLimits = new Map<string, { count: number; resetAt: number }>();
21
+ const RATE_LIMIT_WINDOW = 60_000; // 1 minute
22
+ const RATE_LIMIT_MAX = 10; // max requests per window
23
+
24
+ function isRateLimited(ip: string): boolean {
25
+ const now = Date.now();
26
+ const entry = rateLimits.get(ip);
27
+ if (!entry || now > entry.resetAt) {
28
+ rateLimits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
29
+ return false;
30
+ }
31
+ entry.count++;
32
+ return entry.count > RATE_LIMIT_MAX;
33
+ }
34
+
19
35
  interface LinkResult {
20
36
  url: string;
21
37
  waitForComplete: () => Promise<void>;
@@ -25,6 +41,26 @@ interface LinkResult {
25
41
  export function startLinkServer(): LinkResult {
26
42
  const app = express();
27
43
  app.use(express.json());
44
+
45
+ // Only allow requests from localhost origin
46
+ app.use((req, res, next) => {
47
+ const origin = req.headers.origin || req.headers.referer || "";
48
+ const ip = req.ip || req.socket.remoteAddress || "";
49
+ if (req.path.startsWith("/api/")) {
50
+ // Check origin for API routes
51
+ if (origin && !origin.startsWith(`http://localhost:${config.port}`) && !origin.startsWith(`http://127.0.0.1:${config.port}`)) {
52
+ res.status(403).json({ error: "Forbidden" });
53
+ return;
54
+ }
55
+ // Rate limit API routes
56
+ if (isRateLimited(ip)) {
57
+ res.status(429).json({ error: "Too many requests" });
58
+ return;
59
+ }
60
+ }
61
+ next();
62
+ });
63
+
28
64
  app.use(express.static(resolve(__dirname, "public")));
29
65
 
30
66
  const sessionId = randomUUID();
@@ -139,7 +175,7 @@ export function startLinkServer(): LinkResult {
139
175
  }
140
176
  });
141
177
 
142
- const server = app.listen(config.port);
178
+ const server = app.listen(config.port, "127.0.0.1");
143
179
 
144
180
  const url = `http://localhost:${config.port}/link/${sessionId}`;
145
181
 
package/tsconfig.json CHANGED
@@ -12,5 +12,5 @@
12
12
  "declaration": true
13
13
  },
14
14
  "include": ["src/**/*"],
15
- "exclude": ["node_modules", "dist"]
15
+ "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
16
16
  }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ include: ["src/**/*.test.ts"],
6
+ },
7
+ });