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.
- package/LICENSE +21 -0
- package/README.md +195 -0
- package/dist/ai/agent.d.ts +2 -0
- package/dist/ai/agent.js +93 -0
- package/dist/ai/audit.d.ts +3 -0
- package/dist/ai/audit.js +6 -0
- package/dist/ai/context.d.ts +6 -0
- package/dist/ai/context.js +93 -0
- package/dist/ai/insights.d.ts +3 -0
- package/dist/ai/insights.js +401 -0
- package/dist/ai/memory.d.ts +14 -0
- package/dist/ai/memory.js +12 -0
- package/dist/ai/redactor.d.ts +2 -0
- package/dist/ai/redactor.js +103 -0
- package/dist/ai/system-prompt.d.ts +2 -0
- package/dist/ai/system-prompt.js +85 -0
- package/dist/ai/tools.d.ts +4 -0
- package/dist/ai/tools.js +699 -0
- package/dist/alerts/index.d.ts +11 -0
- package/dist/alerts/index.js +95 -0
- package/dist/auth/anthropic.d.ts +7 -0
- package/dist/auth/anthropic.js +85 -0
- package/dist/auth/pkce.d.ts +5 -0
- package/dist/auth/pkce.js +10 -0
- package/dist/auth/store.d.ts +12 -0
- package/dist/auth/store.js +51 -0
- package/dist/cli/backup.d.ts +2 -0
- package/dist/cli/backup.js +94 -0
- package/dist/cli/chat.d.ts +1 -0
- package/dist/cli/chat.js +203 -0
- package/dist/cli/commands.d.ts +13 -0
- package/dist/cli/commands.js +201 -0
- package/dist/cli/format.d.ts +14 -0
- package/dist/cli/format.js +144 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +186 -0
- package/dist/cli/scheduler.d.ts +2 -0
- package/dist/cli/scheduler.js +114 -0
- package/dist/cli/setup.d.ts +1 -0
- package/dist/cli/setup.js +174 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +60 -0
- package/dist/daily-sync.d.ts +7 -0
- package/dist/daily-sync.js +109 -0
- package/dist/db/connection.d.ts +5 -0
- package/dist/db/connection.js +45 -0
- package/dist/db/encryption.d.ts +3 -0
- package/dist/db/encryption.js +35 -0
- package/dist/db/helpers.d.ts +16 -0
- package/dist/db/helpers.js +45 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +199 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plaid/client.d.ts +2 -0
- package/dist/plaid/client.js +22 -0
- package/dist/plaid/link.d.ts +8 -0
- package/dist/plaid/link.js +23 -0
- package/dist/plaid/sync.d.ts +18 -0
- package/dist/plaid/sync.js +186 -0
- package/dist/public/favicon.png +0 -0
- package/dist/public/link.html +184 -0
- package/dist/public/ray-logo-dark.png +0 -0
- package/dist/queries/index.d.ts +163 -0
- package/dist/queries/index.js +411 -0
- package/dist/scoring/index.d.ts +53 -0
- package/dist/scoring/index.js +375 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +172 -0
- 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
|
+
}
|
package/dist/server.d.ts
ADDED
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
|
+
}
|