ray-finance 0.1.0

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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/ai/agent.d.ts +2 -0
  4. package/dist/ai/agent.js +93 -0
  5. package/dist/ai/audit.d.ts +3 -0
  6. package/dist/ai/audit.js +6 -0
  7. package/dist/ai/context.d.ts +6 -0
  8. package/dist/ai/context.js +93 -0
  9. package/dist/ai/insights.d.ts +3 -0
  10. package/dist/ai/insights.js +401 -0
  11. package/dist/ai/memory.d.ts +14 -0
  12. package/dist/ai/memory.js +12 -0
  13. package/dist/ai/redactor.d.ts +2 -0
  14. package/dist/ai/redactor.js +103 -0
  15. package/dist/ai/system-prompt.d.ts +2 -0
  16. package/dist/ai/system-prompt.js +85 -0
  17. package/dist/ai/tools.d.ts +4 -0
  18. package/dist/ai/tools.js +699 -0
  19. package/dist/alerts/index.d.ts +11 -0
  20. package/dist/alerts/index.js +95 -0
  21. package/dist/auth/anthropic.d.ts +7 -0
  22. package/dist/auth/anthropic.js +85 -0
  23. package/dist/auth/pkce.d.ts +5 -0
  24. package/dist/auth/pkce.js +10 -0
  25. package/dist/auth/store.d.ts +12 -0
  26. package/dist/auth/store.js +51 -0
  27. package/dist/cli/backup.d.ts +2 -0
  28. package/dist/cli/backup.js +94 -0
  29. package/dist/cli/chat.d.ts +1 -0
  30. package/dist/cli/chat.js +203 -0
  31. package/dist/cli/commands.d.ts +13 -0
  32. package/dist/cli/commands.js +201 -0
  33. package/dist/cli/format.d.ts +14 -0
  34. package/dist/cli/format.js +144 -0
  35. package/dist/cli/index.d.ts +2 -0
  36. package/dist/cli/index.js +186 -0
  37. package/dist/cli/scheduler.d.ts +2 -0
  38. package/dist/cli/scheduler.js +114 -0
  39. package/dist/cli/setup.d.ts +1 -0
  40. package/dist/cli/setup.js +174 -0
  41. package/dist/config.d.ts +22 -0
  42. package/dist/config.js +60 -0
  43. package/dist/daily-sync.d.ts +7 -0
  44. package/dist/daily-sync.js +109 -0
  45. package/dist/db/connection.d.ts +5 -0
  46. package/dist/db/connection.js +45 -0
  47. package/dist/db/encryption.d.ts +3 -0
  48. package/dist/db/encryption.js +35 -0
  49. package/dist/db/helpers.d.ts +16 -0
  50. package/dist/db/helpers.js +45 -0
  51. package/dist/db/schema.d.ts +2 -0
  52. package/dist/db/schema.js +199 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.js +1 -0
  55. package/dist/plaid/client.d.ts +2 -0
  56. package/dist/plaid/client.js +22 -0
  57. package/dist/plaid/link.d.ts +8 -0
  58. package/dist/plaid/link.js +23 -0
  59. package/dist/plaid/sync.d.ts +18 -0
  60. package/dist/plaid/sync.js +186 -0
  61. package/dist/public/favicon.png +0 -0
  62. package/dist/public/link.html +184 -0
  63. package/dist/public/ray-logo-dark.png +0 -0
  64. package/dist/queries/index.d.ts +163 -0
  65. package/dist/queries/index.js +411 -0
  66. package/dist/scoring/index.d.ts +53 -0
  67. package/dist/scoring/index.js +375 -0
  68. package/dist/server.d.ts +7 -0
  69. package/dist/server.js +172 -0
  70. package/package.json +60 -0
@@ -0,0 +1,375 @@
1
+ /**
2
+ * Calculate and store the daily score for a given date (defaults to yesterday).
3
+ * Should run during daily sync, after transactions are updated.
4
+ */
5
+ export function calculateDailyScore(db, dateStr) {
6
+ const date = dateStr || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
7
+ const nextDate = new Date(new Date(date).getTime() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
8
+ // Count restaurant/fast food/coffee visits
9
+ const restaurants = db.prepare(`SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total FROM transactions
10
+ WHERE (date = ? OR (date = ? AND pending = 1)) AND amount > 0
11
+ AND category = 'FOOD_AND_DRINK'
12
+ AND (subcategory LIKE '%RESTAURANT%' OR subcategory LIKE '%FAST_FOOD%' OR subcategory LIKE '%COFFEE%')`).get(date, nextDate);
13
+ // Count shopping purchases
14
+ const shopping = db.prepare(`SELECT COUNT(*) as cnt, COALESCE(SUM(amount), 0) as total FROM transactions
15
+ WHERE (date = ? OR (date = ? AND pending = 1)) AND amount > 0
16
+ AND category = 'GENERAL_MERCHANDISE'`).get(date, nextDate);
17
+ // Total food spend
18
+ const food = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
19
+ WHERE (date = ? OR (date = ? AND pending = 1)) AND amount > 0
20
+ AND category = 'FOOD_AND_DRINK'`).get(date, nextDate);
21
+ // Total discretionary spend (exclude fixed bills, transfers, loan payments)
22
+ const totalSpend = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
23
+ WHERE (date = ? OR (date = ? AND pending = 1)) AND amount > 0
24
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS',
25
+ 'LOAN_PAYMENTS_CAR_PAYMENT', 'LOAN_PAYMENTS_PERSONAL_LOAN_PAYMENT',
26
+ 'RENT_AND_UTILITIES', 'RENT_AND_UTILITIES_RENT')`).get(date, nextDate);
27
+ // Get budget pace for food
28
+ const now = new Date(date);
29
+ const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
30
+ const foodBudget = db.prepare(`SELECT monthly_limit FROM budgets WHERE category = 'FOOD_AND_DRINK'`)
31
+ .get();
32
+ const dailyFoodBudget = (foodBudget?.monthly_limit || 900) / daysInMonth;
33
+ // Check if all budgets are on pace
34
+ const dayOfMonth = now.getDate();
35
+ const monthPct = dayOfMonth / daysInMonth;
36
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
37
+ const budgets = db.prepare(`SELECT category, monthly_limit FROM budgets`).all();
38
+ const controllable = ["FOOD_AND_DRINK", "GENERAL_MERCHANDISE", "ENTERTAINMENT", "PERSONAL_CARE", "TRANSPORTATION"];
39
+ let allOnPace = true;
40
+ for (const b of budgets) {
41
+ if (!controllable.includes(b.category))
42
+ continue;
43
+ const spent = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
44
+ WHERE category = ? AND date BETWEEN ? AND ? AND amount > 0`).get(b.category, monthStart, date);
45
+ if (spent.total / b.monthly_limit > monthPct + 0.05) {
46
+ allOnPace = false;
47
+ break;
48
+ }
49
+ }
50
+ const isZeroSpend = totalSpend.total === 0;
51
+ // --- Calculate score ---
52
+ let score = 50; // Base
53
+ // No restaurants: +15
54
+ if (restaurants.cnt === 0)
55
+ score += 15;
56
+ // Each restaurant visit: -10
57
+ else
58
+ score -= restaurants.cnt * 10;
59
+ // No shopping: +15
60
+ if (shopping.cnt === 0)
61
+ score += 15;
62
+ // Each shopping purchase: -10
63
+ else
64
+ score -= shopping.cnt * 10;
65
+ // Food under daily pace: +10
66
+ if (food.total <= dailyFoodBudget)
67
+ score += 10;
68
+ // Food way over (2x pace): -5
69
+ else if (food.total > dailyFoodBudget * 2)
70
+ score -= 5;
71
+ // Total discretionary under $50: +10
72
+ if (totalSpend.total > 0 && totalSpend.total < 50)
73
+ score += 10;
74
+ // Zero-spend day: +25 (jackpot)
75
+ if (isZeroSpend)
76
+ score += 25;
77
+ // Clamp
78
+ score = Math.max(0, Math.min(100, score));
79
+ // --- Streaks (look at previous day's record) ---
80
+ const prev = db.prepare(`SELECT no_restaurant_streak, no_shopping_streak, on_pace_streak FROM daily_scores
81
+ WHERE date < ? ORDER BY date DESC LIMIT 1`).get(date);
82
+ const noRestaurantStreak = restaurants.cnt === 0 ? (prev?.no_restaurant_streak || 0) + 1 : 0;
83
+ const noShoppingStreak = shopping.cnt === 0 ? (prev?.no_shopping_streak || 0) + 1 : 0;
84
+ const onPaceStreak = allOnPace ? (prev?.on_pace_streak || 0) + 1 : 0;
85
+ // Store
86
+ db.prepare(`INSERT INTO daily_scores (date, score, restaurant_count, shopping_count, food_spend, total_spend, zero_spend, no_restaurant_streak, no_shopping_streak, on_pace_streak)
87
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
88
+ ON CONFLICT(date) DO UPDATE SET
89
+ score=excluded.score, restaurant_count=excluded.restaurant_count,
90
+ shopping_count=excluded.shopping_count, food_spend=excluded.food_spend,
91
+ total_spend=excluded.total_spend, zero_spend=excluded.zero_spend,
92
+ no_restaurant_streak=excluded.no_restaurant_streak,
93
+ no_shopping_streak=excluded.no_shopping_streak,
94
+ on_pace_streak=excluded.on_pace_streak`).run(date, score, restaurants.cnt, shopping.cnt, food.total, totalSpend.total, isZeroSpend ? 1 : 0, noRestaurantStreak, noShoppingStreak, onPaceStreak);
95
+ return {
96
+ date,
97
+ score,
98
+ restaurant_count: restaurants.cnt,
99
+ shopping_count: shopping.cnt,
100
+ food_spend: food.total,
101
+ total_spend: totalSpend.total,
102
+ zero_spend: isZeroSpend,
103
+ no_restaurant_streak: noRestaurantStreak,
104
+ no_shopping_streak: noShoppingStreak,
105
+ on_pace_streak: onPaceStreak,
106
+ };
107
+ }
108
+ /**
109
+ * Check and unlock any new achievements. Returns newly unlocked ones.
110
+ */
111
+ export function checkAchievements(db) {
112
+ const newlyUnlocked = [];
113
+ const definitions = [
114
+ {
115
+ key: "clean_week",
116
+ name: "Clean Week",
117
+ description: "7 consecutive days with all budgets on pace",
118
+ check: () => {
119
+ const row = db.prepare(`SELECT on_pace_streak FROM daily_scores ORDER BY date DESC LIMIT 1`).get();
120
+ return row?.on_pace_streak >= 7;
121
+ },
122
+ },
123
+ {
124
+ key: "home_chef",
125
+ name: "Home Chef",
126
+ description: "14-day no-restaurant streak",
127
+ check: () => {
128
+ const row = db.prepare(`SELECT no_restaurant_streak FROM daily_scores ORDER BY date DESC LIMIT 1`).get();
129
+ return row?.no_restaurant_streak >= 14;
130
+ },
131
+ },
132
+ {
133
+ key: "no_restaurant_7",
134
+ name: "Kitchen Hero",
135
+ description: "7-day no-restaurant streak",
136
+ check: () => {
137
+ const row = db.prepare(`SELECT no_restaurant_streak FROM daily_scores ORDER BY date DESC LIMIT 1`).get();
138
+ return row?.no_restaurant_streak >= 7;
139
+ },
140
+ },
141
+ {
142
+ key: "no_shopping_7",
143
+ name: "Window Shopper",
144
+ description: "7 days with zero shopping purchases",
145
+ check: () => {
146
+ const row = db.prepare(`SELECT no_shopping_streak FROM daily_scores ORDER BY date DESC LIMIT 1`).get();
147
+ return row?.no_shopping_streak >= 7;
148
+ },
149
+ },
150
+ {
151
+ key: "no_shopping_14",
152
+ name: "Detoxed",
153
+ description: "14 days with zero shopping purchases",
154
+ check: () => {
155
+ const row = db.prepare(`SELECT no_shopping_streak FROM daily_scores ORDER BY date DESC LIMIT 1`).get();
156
+ return row?.no_shopping_streak >= 14;
157
+ },
158
+ },
159
+ {
160
+ key: "zero_hero",
161
+ name: "Zero Hero",
162
+ description: "A zero-spend day",
163
+ check: () => {
164
+ const row = db.prepare(`SELECT COUNT(*) as cnt FROM daily_scores WHERE zero_spend = 1`).get();
165
+ return row?.cnt >= 1;
166
+ },
167
+ },
168
+ {
169
+ key: "zero_hero_5",
170
+ name: "Monk Mode",
171
+ description: "5 zero-spend days in a single month",
172
+ check: () => {
173
+ const now = new Date();
174
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
175
+ const row = db.prepare(`SELECT COUNT(*) as cnt FROM daily_scores WHERE zero_spend = 1 AND date >= ?`).get(monthStart);
176
+ return row?.cnt >= 5;
177
+ },
178
+ },
179
+ {
180
+ key: "debt_crusher",
181
+ name: "Debt Crusher",
182
+ description: "$1,000+ paid toward credit card in a single month",
183
+ check: () => {
184
+ const now = new Date();
185
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
186
+ const row = db.prepare(`SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM transactions
187
+ WHERE account_id IN (SELECT account_id FROM accounts WHERE type = 'credit')
188
+ AND amount < 0 AND date >= ? AND category != 'TRANSFER_IN'`).get(monthStart);
189
+ return row?.total >= 1000;
190
+ },
191
+ },
192
+ {
193
+ key: "food_discipline",
194
+ name: "Half Marathon",
195
+ description: "Food budget under target for a full month",
196
+ check: () => {
197
+ const now = new Date();
198
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10);
199
+ const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10);
200
+ const budget = db.prepare(`SELECT monthly_limit FROM budgets WHERE category = 'FOOD_AND_DRINK'`).get();
201
+ if (!budget)
202
+ return false;
203
+ const spent = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
204
+ WHERE category = 'FOOD_AND_DRINK' AND date BETWEEN ? AND ? AND amount > 0`).get(lastMonthStart, lastMonthEnd);
205
+ return spent?.total <= budget.monthly_limit;
206
+ },
207
+ },
208
+ {
209
+ key: "net_worth_500k",
210
+ name: "Half Millionaire",
211
+ description: "Net worth crossed $500,000",
212
+ check: () => {
213
+ const row = db.prepare(`SELECT net_worth FROM net_worth_history ORDER BY date DESC LIMIT 1`).get();
214
+ return row?.net_worth >= 500000;
215
+ },
216
+ },
217
+ {
218
+ key: "bnpl_plan_done",
219
+ name: "One Down",
220
+ description: "A BNPL plan completed",
221
+ check: () => {
222
+ const now = new Date();
223
+ const thisMonthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
224
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10);
225
+ const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10);
226
+ const lastMonth = db.prepare(`SELECT DISTINCT merchant_name FROM transactions
227
+ WHERE category = 'LOAN_PAYMENTS' AND (merchant_name LIKE '%Affirm%' OR name LIKE '%PAYMTHLY%')
228
+ AND date BETWEEN ? AND ? AND amount > 0`).all(lastMonthStart, lastMonthEnd);
229
+ const thisMonth = db.prepare(`SELECT DISTINCT merchant_name FROM transactions
230
+ WHERE category = 'LOAN_PAYMENTS' AND (merchant_name LIKE '%Affirm%' OR name LIKE '%PAYMTHLY%')
231
+ AND date >= ? AND amount > 0`).all(thisMonthStart);
232
+ const thisMonthNames = new Set(thisMonth.map(r => r.merchant_name));
233
+ return lastMonth.some(r => !thisMonthNames.has(r.merchant_name));
234
+ },
235
+ },
236
+ {
237
+ key: "emergency_2k",
238
+ name: "Safety Net",
239
+ description: "Emergency fund hit $2,000",
240
+ check: () => {
241
+ const goal = db.prepare(`SELECT current_amount FROM goals WHERE name LIKE '%Emergency%'`).get();
242
+ return goal?.current_amount >= 2000;
243
+ },
244
+ },
245
+ {
246
+ key: "score_90_streak_3",
247
+ name: "On Fire",
248
+ description: "Score of 90+ three days in a row",
249
+ check: () => {
250
+ const rows = db.prepare(`SELECT score FROM daily_scores ORDER BY date DESC LIMIT 3`).all();
251
+ return rows.length >= 3 && rows.every(r => r.score >= 90);
252
+ },
253
+ },
254
+ {
255
+ key: "avg_score_80",
256
+ name: "Consistent",
257
+ description: "Average score of 80+ over a full month",
258
+ check: () => {
259
+ const now = new Date();
260
+ const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1).toISOString().slice(0, 10);
261
+ const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0).toISOString().slice(0, 10);
262
+ const row = db.prepare(`SELECT AVG(score) as avg_score, COUNT(*) as cnt FROM daily_scores WHERE date BETWEEN ? AND ?`).get(lastMonthStart, lastMonthEnd);
263
+ return row?.cnt >= 20 && row?.avg_score >= 80;
264
+ },
265
+ },
266
+ ];
267
+ for (const def of definitions) {
268
+ // Skip if already unlocked
269
+ const existing = db.prepare(`SELECT 1 FROM achievements WHERE key = ?`).get(def.key);
270
+ if (existing)
271
+ continue;
272
+ if (def.check()) {
273
+ db.prepare(`INSERT INTO achievements (key, name, description) VALUES (?, ?, ?)`).run(def.key, def.name, def.description);
274
+ newlyUnlocked.push({
275
+ key: def.key,
276
+ name: def.name,
277
+ description: def.description,
278
+ unlocked_at: new Date().toISOString(),
279
+ });
280
+ }
281
+ }
282
+ return newlyUnlocked;
283
+ }
284
+ /**
285
+ * Get monthly savings compared to the earliest full month of transaction data (dynamic baseline).
286
+ * If no full month exists yet, returns { saved: 0, ... }.
287
+ */
288
+ export function getMonthlySavings(db) {
289
+ const now = new Date();
290
+ const dayOfMonth = now.getDate();
291
+ // Find the earliest full month of transaction data
292
+ const earliest = db.prepare(`SELECT MIN(date) as min_date FROM transactions WHERE amount > 0`).get();
293
+ if (!earliest.min_date) {
294
+ return { saved: 0, baselinePace: 0, currentPace: 0, daysCompared: 0, baselineMonth: null };
295
+ }
296
+ const earliestDate = new Date(earliest.min_date);
297
+ // The first full month starts on the 1st of the month after the earliest transaction
298
+ // unless the earliest transaction IS on the 1st
299
+ let baselineYear = earliestDate.getFullYear();
300
+ let baselineMonth = earliestDate.getMonth();
301
+ if (earliestDate.getDate() > 1) {
302
+ // Move to next month for a full month
303
+ baselineMonth += 1;
304
+ if (baselineMonth > 11) {
305
+ baselineMonth = 0;
306
+ baselineYear += 1;
307
+ }
308
+ }
309
+ // Make sure baseline month is fully in the past
310
+ const baselineEnd = new Date(baselineYear, baselineMonth + 1, 0);
311
+ if (baselineEnd >= now) {
312
+ // Baseline month hasn't ended yet
313
+ return { saved: 0, baselinePace: 0, currentPace: 0, daysCompared: 0, baselineMonth: null };
314
+ }
315
+ // Also skip if baseline is the current month
316
+ if (baselineYear === now.getFullYear() && baselineMonth === now.getMonth()) {
317
+ return { saved: 0, baselinePace: 0, currentPace: 0, daysCompared: 0, baselineMonth: null };
318
+ }
319
+ const baselineStart = `${baselineYear}-${String(baselineMonth + 1).padStart(2, "0")}-01`;
320
+ const baselineEndStr = baselineEnd.toISOString().slice(0, 10);
321
+ const daysInBaseline = baselineEnd.getDate();
322
+ const baselineLabel = `${baselineYear}-${String(baselineMonth + 1).padStart(2, "0")}`;
323
+ // Baseline: total discretionary spending
324
+ const baselineTotal = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
325
+ WHERE date BETWEEN ? AND ? AND amount > 0
326
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS',
327
+ 'LOAN_PAYMENTS_CAR_PAYMENT', 'LOAN_PAYMENTS_PERSONAL_LOAN_PAYMENT',
328
+ 'RENT_AND_UTILITIES', 'RENT_AND_UTILITIES_RENT')`).get(baselineStart, baselineEndStr);
329
+ // Baseline daily pace
330
+ const baselineDailyPace = baselineTotal.total / daysInBaseline;
331
+ // This month's actual spend so far
332
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
333
+ const today = now.toISOString().slice(0, 10);
334
+ const currentTotal = db.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM transactions
335
+ WHERE date BETWEEN ? AND ? AND amount > 0
336
+ AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS',
337
+ 'LOAN_PAYMENTS_CAR_PAYMENT', 'LOAN_PAYMENTS_PERSONAL_LOAN_PAYMENT',
338
+ 'RENT_AND_UTILITIES', 'RENT_AND_UTILITIES_RENT')`).get(monthStart, today);
339
+ const baselinePaceForDays = baselineDailyPace * dayOfMonth;
340
+ const saved = baselinePaceForDays - currentTotal.total;
341
+ return {
342
+ saved: Math.round(saved * 100) / 100,
343
+ baselinePace: Math.round(baselinePaceForDays * 100) / 100,
344
+ currentPace: Math.round(currentTotal.total * 100) / 100,
345
+ daysCompared: dayOfMonth,
346
+ baselineMonth: baselineLabel,
347
+ };
348
+ }
349
+ /**
350
+ * Get latest score + streaks for display.
351
+ */
352
+ export function getLatestScore(db) {
353
+ const row = db.prepare(`SELECT * FROM daily_scores ORDER BY date DESC LIMIT 1`).get();
354
+ if (!row)
355
+ return null;
356
+ return {
357
+ ...row,
358
+ zero_spend: row.zero_spend === 1,
359
+ };
360
+ }
361
+ /**
362
+ * Get all unlocked achievements.
363
+ */
364
+ export function getAchievements(db) {
365
+ return db.prepare(`SELECT * FROM achievements ORDER BY unlocked_at DESC`).all();
366
+ }
367
+ /**
368
+ * Get average score for current month.
369
+ */
370
+ export function getMonthAvgScore(db) {
371
+ const now = new Date();
372
+ const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
373
+ const row = db.prepare(`SELECT AVG(score) as avg FROM daily_scores WHERE date >= ?`).get(monthStart);
374
+ return row.avg !== null ? Math.round(row.avg) : null;
375
+ }
@@ -0,0 +1,7 @@
1
+ interface LinkResult {
2
+ url: string;
3
+ waitForComplete: () => Promise<void>;
4
+ stop: () => void;
5
+ }
6
+ export declare function startLinkServer(): LinkResult;
7
+ export {};
package/dist/server.js ADDED
@@ -0,0 +1,172 @@
1
+ import express from "express";
2
+ import { fileURLToPath } from "url";
3
+ import { dirname, resolve } from "path";
4
+ import { randomUUID } from "crypto";
5
+ import { createLinkToken, exchangeToken } from "./plaid/link.js";
6
+ import { syncBalances, syncTransactions } from "./plaid/sync.js";
7
+ import { plaidClient } from "./plaid/client.js";
8
+ import { CountryCode } from "plaid";
9
+ import { encryptPlaidToken } from "./db/encryption.js";
10
+ import { config } from "./config.js";
11
+ import { getDb } from "./db/connection.js";
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ // Session map for Plaid Link — maps sessionId → true (single-user, no chatId needed)
15
+ const linkSessions = new Map();
16
+ // Simple rate limiter: track request counts per IP
17
+ const rateLimits = new Map();
18
+ const RATE_LIMIT_WINDOW = 60_000; // 1 minute
19
+ const RATE_LIMIT_MAX = 10; // max requests per window
20
+ function isRateLimited(ip) {
21
+ const now = Date.now();
22
+ const entry = rateLimits.get(ip);
23
+ if (!entry || now > entry.resetAt) {
24
+ rateLimits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW });
25
+ return false;
26
+ }
27
+ entry.count++;
28
+ return entry.count > RATE_LIMIT_MAX;
29
+ }
30
+ export function startLinkServer() {
31
+ const app = express();
32
+ app.use(express.json());
33
+ // Only allow requests from localhost origin
34
+ app.use((req, res, next) => {
35
+ const origin = req.headers.origin || req.headers.referer || "";
36
+ const ip = req.ip || req.socket.remoteAddress || "";
37
+ if (req.path.startsWith("/api/")) {
38
+ // Check origin for API routes
39
+ if (origin && !origin.startsWith(`http://localhost:${config.port}`) && !origin.startsWith(`http://127.0.0.1:${config.port}`)) {
40
+ res.status(403).json({ error: "Forbidden" });
41
+ return;
42
+ }
43
+ // Rate limit API routes
44
+ if (isRateLimited(ip)) {
45
+ res.status(429).json({ error: "Too many requests" });
46
+ return;
47
+ }
48
+ }
49
+ next();
50
+ });
51
+ app.use(express.static(resolve(__dirname, "public")));
52
+ const sessionId = randomUUID();
53
+ linkSessions.set(sessionId, true);
54
+ let resolveComplete;
55
+ const completePromise = new Promise((res) => { resolveComplete = res; });
56
+ // Serve Plaid Link page
57
+ app.get("/link/:session", (req, res) => {
58
+ const sid = req.params.session;
59
+ if (!linkSessions.has(sid)) {
60
+ res.status(404).send("Link session expired or invalid. Please run 'ray link' again.");
61
+ return;
62
+ }
63
+ res.sendFile(resolve(__dirname, "public", "link.html"));
64
+ });
65
+ // Create link token
66
+ app.post("/api/link-token", async (req, res) => {
67
+ try {
68
+ const { session_id } = req.body;
69
+ if (!linkSessions.has(session_id)) {
70
+ res.status(404).json({ error: "Invalid or expired session" });
71
+ return;
72
+ }
73
+ const linkToken = await createLinkToken();
74
+ res.json({ link_token: linkToken });
75
+ }
76
+ catch (error) {
77
+ console.error("Link token error:", error.message);
78
+ res.status(500).json({ error: "Failed to create link token" });
79
+ }
80
+ });
81
+ // Exchange public token
82
+ app.post("/api/exchange", async (req, res) => {
83
+ try {
84
+ const { public_token, session_id, institution_name } = req.body;
85
+ if (!linkSessions.has(session_id)) {
86
+ res.status(404).json({ error: "Invalid or expired session" });
87
+ return;
88
+ }
89
+ const db = getDb();
90
+ const { accessToken, itemId } = await exchangeToken(public_token);
91
+ // Encrypt token — refuse to store without encryption
92
+ if (!config.plaidTokenSecret) {
93
+ res.status(500).json({ error: "Plaid token secret not configured. Run 'ray setup' to set one." });
94
+ return;
95
+ }
96
+ const encryptedToken = encryptPlaidToken(accessToken, config.plaidTokenSecret);
97
+ db.prepare(`INSERT INTO institutions (item_id, access_token, name, products)
98
+ VALUES (?, ?, ?, ?)
99
+ ON CONFLICT(item_id) DO UPDATE SET access_token = excluded.access_token`).run(itemId, encryptedToken, institution_name || "Account", JSON.stringify(["transactions"]));
100
+ // Trigger initial sync (Plaid may not have data ready immediately)
101
+ const runSync = async () => {
102
+ await syncBalances(db, accessToken);
103
+ await syncTransactions(db, itemId, accessToken, null);
104
+ };
105
+ try {
106
+ await runSync();
107
+ }
108
+ catch (syncErr) {
109
+ console.error("Initial sync error, will retry in 30s:", syncErr.message);
110
+ // Plaid often needs time to prepare data after first link
111
+ setTimeout(async () => {
112
+ try {
113
+ await runSync();
114
+ console.log("Retry sync succeeded for", institution_name);
115
+ }
116
+ catch (retryErr) {
117
+ console.error("Retry sync also failed:", retryErr.message);
118
+ // One more retry after 2 minutes
119
+ setTimeout(async () => {
120
+ try {
121
+ await runSync();
122
+ console.log("Final retry sync succeeded for", institution_name);
123
+ }
124
+ catch (finalErr) {
125
+ console.error("Final sync retry failed:", finalErr.message);
126
+ }
127
+ }, 120_000);
128
+ }
129
+ }, 30_000);
130
+ }
131
+ // Fetch institution logo
132
+ let institutionLogo = null;
133
+ if (req.body.institution_id) {
134
+ try {
135
+ const { data } = await plaidClient.institutionsGetById({
136
+ institution_id: req.body.institution_id,
137
+ country_codes: [CountryCode.Us],
138
+ options: { include_optional_metadata: true },
139
+ });
140
+ institutionLogo = data.institution.logo || null;
141
+ }
142
+ catch { }
143
+ }
144
+ // Clean up session
145
+ linkSessions.delete(session_id);
146
+ res.json({ success: true, institution_name: institution_name, institution_logo: institutionLogo });
147
+ // Signal completion
148
+ resolveComplete();
149
+ }
150
+ catch (error) {
151
+ console.error("Token exchange error:", error.message);
152
+ res.status(500).json({ error: "Failed to link account" });
153
+ }
154
+ });
155
+ const server = app.listen(config.port, "127.0.0.1");
156
+ const url = `http://localhost:${config.port}/link/${sessionId}`;
157
+ // Auto-expire after 30 minutes
158
+ const timeout = setTimeout(() => {
159
+ linkSessions.delete(sessionId);
160
+ server.close();
161
+ resolveComplete();
162
+ }, 30 * 60 * 1000);
163
+ return {
164
+ url,
165
+ waitForComplete: () => completePromise,
166
+ stop: () => {
167
+ clearTimeout(timeout);
168
+ linkSessions.clear();
169
+ server.close();
170
+ },
171
+ };
172
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "ray-finance",
3
+ "version": "0.1.0",
4
+ "description": "Local-first CLI that turns your bank data into a personal AI financial advisor",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Clark Dinnison",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/cdinnison/ray-finance.git"
11
+ },
12
+ "homepage": "https://rayfinance.app",
13
+ "bugs": {
14
+ "url": "https://github.com/cdinnison/ray-finance/issues"
15
+ },
16
+ "keywords": [
17
+ "finance",
18
+ "personal-finance",
19
+ "cli",
20
+ "ai",
21
+ "plaid",
22
+ "budgeting",
23
+ "local-first"
24
+ ],
25
+ "engines": {
26
+ "node": ">=18"
27
+ },
28
+ "files": [
29
+ "dist/"
30
+ ],
31
+ "bin": {
32
+ "ray": "./dist/cli/index.js"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc && rm -rf dist/public && cp -r src/public dist/public",
36
+ "test": "vitest run",
37
+ "prepublishOnly": "npm run build",
38
+ "sync": "tsx src/daily-sync.ts"
39
+ },
40
+ "dependencies": {
41
+ "@anthropic-ai/sdk": "^0.74.0",
42
+ "libsql": "^0.5.0",
43
+ "chalk": "^5.3.0",
44
+ "commander": "^13.0.0",
45
+ "dotenv": "^16.4.0",
46
+ "express": "^4.21.0",
47
+ "inquirer": "^12.0.0",
48
+ "open": "^10.0.0",
49
+ "ora": "^8.0.0",
50
+ "plaid": "^28.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@types/better-sqlite3": "^7.6.0",
54
+ "@types/express": "^4.17.0",
55
+ "@types/node": "^22.0.0",
56
+ "tsx": "^4.7.0",
57
+ "typescript": "^5.5.0",
58
+ "vitest": "^3.2.4"
59
+ }
60
+ }