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.
- package/.claude/settings.local.json +3 -1
- package/.github/ray-logo.png +0 -0
- package/.github/workflows/ci.yml +1 -0
- package/Dockerfile +2 -2
- package/README.md +31 -10
- package/SECURITY.md +1 -1
- package/dist/ai/agent.js +16 -3
- package/dist/ai/context.js +6 -2
- package/dist/ai/insights.js +26 -3
- package/dist/ai/redactor.js +11 -0
- package/dist/ai/system-prompt.js +2 -2
- package/dist/ai/tools.js +4 -0
- package/dist/cli/backup.js +18 -9
- package/dist/cli/chat.js +146 -40
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.js +25 -0
- package/dist/cli/index.js +12 -2
- package/dist/cli/setup.js +7 -1
- package/dist/daily-sync.js +19 -4
- package/dist/db/connection.js +9 -1
- package/dist/db/encryption.js +18 -7
- package/dist/db/schema.js +6 -1
- package/dist/public/favicon.png +0 -0
- package/dist/public/link.html +47 -24
- package/dist/public/ray-logo-dark.png +0 -0
- package/dist/queries/index.js +8 -8
- package/dist/server.js +33 -1
- package/package.json +4 -2
- package/site/package-lock.json +43 -0
- package/site/package.json +1 -0
- package/site/public/ray-logo-dark.png +0 -0
- package/site/public/ray-logo-light.png +0 -0
- package/site/src/app/copy-command.tsx +1 -3
- package/site/src/app/layout.tsx +2 -1
- package/src/ai/agent.ts +15 -3
- package/src/ai/context.ts +3 -2
- package/src/ai/insights.ts +25 -3
- package/src/ai/redactor.test.ts +63 -0
- package/src/ai/redactor.ts +12 -0
- package/src/ai/system-prompt.ts +2 -2
- package/src/ai/tools.ts +4 -0
- package/src/cli/backup.ts +23 -10
- package/src/cli/chat.ts +155 -41
- package/src/cli/format.ts +31 -0
- package/src/cli/index.ts +12 -2
- package/src/cli/setup.ts +6 -1
- package/src/daily-sync.test.ts +150 -0
- package/src/daily-sync.ts +19 -4
- package/src/db/connection.ts +12 -1
- package/src/db/encryption.test.ts +86 -0
- package/src/db/encryption.ts +17 -7
- package/src/db/schema.test.ts +53 -0
- package/src/db/schema.ts +7 -1
- package/src/public/favicon.png +0 -0
- package/src/public/link.html +47 -24
- package/src/public/ray-logo-dark.png +0 -0
- package/src/queries/index.test.ts +397 -0
- package/src/queries/index.ts +8 -8
- package/src/server.ts +37 -1
- package/tsconfig.json +1 -1
- package/vitest.config.ts +7 -0
- 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
|
+
});
|
package/src/queries/index.ts
CHANGED
|
@@ -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.
|
|
400
|
-
h.quantity, h.
|
|
401
|
-
(h.
|
|
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.
|
|
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.
|
|
435
|
-
h.
|
|
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.
|
|
439
|
-
ORDER BY h.
|
|
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