ray-finance 0.3.3 → 0.3.5
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/README.md +34 -16
- package/dist/cli/chat.js +183 -8
- package/dist/cli/commands.d.ts +2 -0
- package/dist/cli/commands.js +136 -1
- package/dist/cli/completions.js +2 -1
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/index.js +21 -9
- package/dist/cli/setup.js +1 -1
- package/dist/daily-sync.js +1 -1
- package/dist/queries/index.js +10 -1
- package/dist/server.js +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
An open-source
|
|
6
|
+
An open-source AI financial advisor that learns your situation and gets smarter every conversation.
|
|
7
7
|
</p>
|
|
8
8
|
|
|
9
9
|
<p align="center">
|
|
@@ -15,26 +15,37 @@
|
|
|
15
15
|
<br />
|
|
16
16
|
|
|
17
17
|
<p align="center">
|
|
18
|
-
<img src=".github/ray-demo.png" alt="Ray demo" />
|
|
18
|
+
<img src=".github/ray-demo.png" alt="Ray demo" width="100%" />
|
|
19
19
|
</p>
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Tell Ray about your family, goals, and financial strategy once. From then on, every answer is grounded in your real situation — not generic advice. It connects to your bank, tracks your net worth and spending, and gives you a financial briefing before you type a word. Open source. Local-first. Encrypted.
|
|
22
22
|
|
|
23
23
|
## Features
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
29
|
-
- **
|
|
30
|
-
|
|
25
|
+
### It gets smarter every conversation
|
|
26
|
+
|
|
27
|
+
- **Your situation, always loaded** — Every conversation starts with your financial profile: family, income, goals, strategy, key decisions, and open items. Ray reads it all before you type a word.
|
|
28
|
+
- **Self-updating context** — Got a raise? Had a baby? Decided to pay off debt aggressively? Ray updates your profile automatically when your situation changes.
|
|
29
|
+
- **Long-term memory** — Mention you're saving for a house or that you cancelled a subscription. Ray remembers across every future conversation.
|
|
30
|
+
|
|
31
|
+
### Stay on track without trying
|
|
32
|
+
|
|
31
33
|
- **CFO personality** — Ray doesn't list options. It tells you what it would do and why, references your goals, and flags problems you haven't noticed yet.
|
|
32
|
-
- **
|
|
34
|
+
- **Daily scoring** — A 0-100 behavior score with streaks and 14 unlockable achievements. No restaurants for a week? That's Kitchen Hero. Five zero-spend days? Monk Mode.
|
|
35
|
+
- **Budgets and goals** — Track spending limits by category and progress toward financial goals.
|
|
36
|
+
- **Smart alerts** — Large transactions, low balances, budget overruns.
|
|
37
|
+
|
|
38
|
+
### Your data never leaves your machine
|
|
39
|
+
|
|
40
|
+
- **Encrypted local database** — All data stays on your machine in an AES-256 encrypted SQLite database.
|
|
33
41
|
- **PII masking** — Names, account numbers, and identifying details are scrubbed before anything reaches the AI. Your data is analyzed, not exposed.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
- **
|
|
42
|
+
|
|
43
|
+
### Set it and forget it
|
|
44
|
+
|
|
45
|
+
- **Bank sync via Plaid** — Connect checking, savings, credit cards, investments, and loans.
|
|
46
|
+
- **Scheduled daily sync** — Automatic bank sync via launchd (macOS) or cron (Linux).
|
|
47
|
+
- **Auto-recategorization** — Define rules to automatically re-label transactions.
|
|
48
|
+
- **Export/import** — Back up and restore your financial data.
|
|
38
49
|
|
|
39
50
|
## Install
|
|
40
51
|
|
|
@@ -70,7 +81,7 @@ ray setup
|
|
|
70
81
|
|
|
71
82
|
The setup wizard offers two modes:
|
|
72
83
|
|
|
73
|
-
###
|
|
84
|
+
### Pro (quick setup)
|
|
74
85
|
|
|
75
86
|
We handle the API keys. Your data stays local. $10/mo.
|
|
76
87
|
|
|
@@ -79,7 +90,7 @@ We handle the API keys. Your data stays local. $10/mo.
|
|
|
79
90
|
3. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
|
|
80
91
|
4. Done — daily sync auto-scheduled at 6am
|
|
81
92
|
|
|
82
|
-
###
|
|
93
|
+
### Bring your own keys
|
|
83
94
|
|
|
84
95
|
Bring your own Anthropic and Plaid credentials. Free forever.
|
|
85
96
|
|
|
@@ -99,17 +110,24 @@ Run `ray --help` to see all available commands.
|
|
|
99
110
|
| `ray --demo <cmd>` | Run any command against demo data |
|
|
100
111
|
| `ray setup` | Configure API keys and preferences |
|
|
101
112
|
| `ray link` | Connect a new bank account |
|
|
113
|
+
| `ray add` | Add a manual account (home, car, crypto, etc.) |
|
|
114
|
+
| `ray remove` | Remove a manual account |
|
|
102
115
|
| `ray sync` | Pull latest transactions and balances |
|
|
103
116
|
| `ray status` | Quick financial dashboard |
|
|
117
|
+
| `ray accounts` | Linked accounts with balances |
|
|
104
118
|
| `ray transactions` | Recent transactions (filterable by category, merchant) |
|
|
105
119
|
| `ray spending [period]` | Spending breakdown by category |
|
|
106
120
|
| `ray budgets` | Budget status and overruns |
|
|
107
121
|
| `ray goals` | Financial goal progress |
|
|
122
|
+
| `ray bills` | Upcoming bills |
|
|
123
|
+
| `ray recap [period]` | Monthly spending recap |
|
|
108
124
|
| `ray score` | Daily score, streaks, and achievements |
|
|
109
125
|
| `ray alerts` | Active financial alerts |
|
|
110
126
|
| `ray export [path]` | Export data to a backup file |
|
|
111
127
|
| `ray import <path>` | Restore from a backup file |
|
|
112
128
|
| `ray billing` | Manage your Ray subscription (managed mode only) |
|
|
129
|
+
| `ray update` | Update Ray to the latest version |
|
|
130
|
+
| `ray doctor` | Check system health |
|
|
113
131
|
|
|
114
132
|
## How It Works
|
|
115
133
|
|
package/dist/cli/chat.js
CHANGED
|
@@ -5,7 +5,35 @@ import { banner, formatResponse, formatDuration, formatError } from "./format.js
|
|
|
5
5
|
function rawReadLine(prompt, belowLines) {
|
|
6
6
|
return new Promise((resolve) => {
|
|
7
7
|
let buf = "";
|
|
8
|
+
let cursor = 0; // cursor position within buf
|
|
8
9
|
const out = process.stdout;
|
|
10
|
+
const promptLen = stripAnsi(prompt).length;
|
|
11
|
+
// Redraws the buffer from the prompt onward and repositions the cursor
|
|
12
|
+
const redraw = () => {
|
|
13
|
+
out.write("\r" + prompt + buf + "\x1b[K"); // clear to end of line
|
|
14
|
+
// Move cursor back to the correct position
|
|
15
|
+
const back = buf.length - cursor;
|
|
16
|
+
if (back > 0)
|
|
17
|
+
out.write(`\x1b[${back}D`);
|
|
18
|
+
};
|
|
19
|
+
// Find the start of the previous word boundary
|
|
20
|
+
const wordLeft = () => {
|
|
21
|
+
let p = cursor;
|
|
22
|
+
while (p > 0 && buf[p - 1] === " ")
|
|
23
|
+
p--; // skip trailing spaces
|
|
24
|
+
while (p > 0 && buf[p - 1] !== " ")
|
|
25
|
+
p--; // skip word chars
|
|
26
|
+
return p;
|
|
27
|
+
};
|
|
28
|
+
// Find the end of the next word boundary
|
|
29
|
+
const wordRight = () => {
|
|
30
|
+
let p = cursor;
|
|
31
|
+
while (p < buf.length && buf[p] !== " ")
|
|
32
|
+
p++; // skip word chars
|
|
33
|
+
while (p < buf.length && buf[p] === " ")
|
|
34
|
+
p++; // skip trailing spaces
|
|
35
|
+
return p;
|
|
36
|
+
};
|
|
9
37
|
// Render: prompt on current line, then content below, then move cursor back
|
|
10
38
|
out.write(prompt);
|
|
11
39
|
if (belowLines.length > 0) {
|
|
@@ -32,9 +60,51 @@ function rawReadLine(prompt, belowLines) {
|
|
|
32
60
|
resolve("\x03");
|
|
33
61
|
return;
|
|
34
62
|
}
|
|
63
|
+
// Ctrl+A — beginning of line
|
|
64
|
+
if (code === 1) {
|
|
65
|
+
if (cursor > 0) {
|
|
66
|
+
out.write(`\x1b[${cursor}D`);
|
|
67
|
+
cursor = 0;
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// Ctrl+E — end of line
|
|
72
|
+
if (code === 5) {
|
|
73
|
+
if (cursor < buf.length) {
|
|
74
|
+
out.write(`\x1b[${buf.length - cursor}C`);
|
|
75
|
+
cursor = buf.length;
|
|
76
|
+
}
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
// Ctrl+K — delete from cursor to end of line
|
|
80
|
+
if (code === 11) {
|
|
81
|
+
buf = buf.slice(0, cursor);
|
|
82
|
+
out.write("\x1b[K");
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
// Ctrl+U — delete from cursor to beginning of line
|
|
86
|
+
if (code === 21) {
|
|
87
|
+
buf = buf.slice(cursor);
|
|
88
|
+
cursor = 0;
|
|
89
|
+
redraw();
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
// Ctrl+W — delete word backward
|
|
93
|
+
if (code === 23) {
|
|
94
|
+
if (cursor > 0) {
|
|
95
|
+
const target = wordLeft();
|
|
96
|
+
buf = buf.slice(0, target) + buf.slice(cursor);
|
|
97
|
+
cursor = target;
|
|
98
|
+
redraw();
|
|
99
|
+
}
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
35
102
|
// Enter
|
|
36
103
|
if (code === 13) {
|
|
37
104
|
cleanup();
|
|
105
|
+
// Move cursor to end of buf first
|
|
106
|
+
if (cursor < buf.length)
|
|
107
|
+
out.write(`\x1b[${buf.length - cursor}C`);
|
|
38
108
|
// Move past the below-content lines, then newline
|
|
39
109
|
for (let j = 0; j < belowLines.length; j++)
|
|
40
110
|
out.write("\x1b[1B");
|
|
@@ -44,31 +114,136 @@ function rawReadLine(prompt, belowLines) {
|
|
|
44
114
|
}
|
|
45
115
|
// Backspace
|
|
46
116
|
if (code === 127 || code === 8) {
|
|
47
|
-
if (
|
|
48
|
-
buf = buf.slice(0, -1);
|
|
49
|
-
|
|
117
|
+
if (cursor > 0) {
|
|
118
|
+
buf = buf.slice(0, cursor - 1) + buf.slice(cursor);
|
|
119
|
+
cursor--;
|
|
120
|
+
redraw();
|
|
50
121
|
}
|
|
51
122
|
continue;
|
|
52
123
|
}
|
|
53
|
-
//
|
|
124
|
+
// Escape sequences (arrow keys, Option+key, etc.)
|
|
54
125
|
if (code === 27) {
|
|
126
|
+
// Option+Backspace — ESC followed by DEL (0x7f)
|
|
127
|
+
if (i + 1 < chunk.length && chunk.charCodeAt(i + 1) === 127) {
|
|
128
|
+
i++; // consume the DEL
|
|
129
|
+
if (cursor > 0) {
|
|
130
|
+
const target = wordLeft();
|
|
131
|
+
buf = buf.slice(0, target) + buf.slice(cursor);
|
|
132
|
+
cursor = target;
|
|
133
|
+
redraw();
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
// Option+b / Option+f — ESC followed by 'b' or 'f'
|
|
138
|
+
if (i + 1 < chunk.length && chunk[i + 1] === "b") {
|
|
139
|
+
i++;
|
|
140
|
+
const target = wordLeft();
|
|
141
|
+
if (target < cursor) {
|
|
142
|
+
out.write(`\x1b[${cursor - target}D`);
|
|
143
|
+
cursor = target;
|
|
144
|
+
}
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (i + 1 < chunk.length && chunk[i + 1] === "f") {
|
|
148
|
+
i++;
|
|
149
|
+
const target = wordRight();
|
|
150
|
+
if (target > cursor) {
|
|
151
|
+
out.write(`\x1b[${target - cursor}C`);
|
|
152
|
+
cursor = target;
|
|
153
|
+
}
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
55
156
|
if (i + 1 < chunk.length && chunk[i + 1] === "[") {
|
|
56
|
-
i += 2;
|
|
57
|
-
|
|
157
|
+
i += 2; // skip past ESC [
|
|
158
|
+
// Collect any intermediate bytes (modifiers like "1;3")
|
|
159
|
+
let seq = "";
|
|
160
|
+
while (i < chunk.length && chunk.charCodeAt(i) < 64) {
|
|
161
|
+
seq += chunk[i];
|
|
58
162
|
i++;
|
|
163
|
+
}
|
|
164
|
+
if (i < chunk.length) {
|
|
165
|
+
const final = chunk[i];
|
|
166
|
+
// Modifier keys: ;3 = Option, ;5 = Ctrl, ;9 = Cmd (Kitty protocol)
|
|
167
|
+
const isWordMod = seq === "1;3" || seq === "1;5" || seq === "1;9";
|
|
168
|
+
const isCmd = seq === "1;9";
|
|
169
|
+
if (final === "D") {
|
|
170
|
+
if (isWordMod) {
|
|
171
|
+
// Option/Ctrl/Cmd+Left — word backward
|
|
172
|
+
const target = wordLeft();
|
|
173
|
+
if (target < cursor) {
|
|
174
|
+
out.write(`\x1b[${cursor - target}D`);
|
|
175
|
+
cursor = target;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
else if (cursor > 0) {
|
|
179
|
+
cursor--;
|
|
180
|
+
out.write("\x1b[D");
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
else if (final === "C") {
|
|
184
|
+
if (isWordMod) {
|
|
185
|
+
// Option/Ctrl/Cmd+Right — word forward
|
|
186
|
+
const target = wordRight();
|
|
187
|
+
if (target > cursor) {
|
|
188
|
+
out.write(`\x1b[${target - cursor}C`);
|
|
189
|
+
cursor = target;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (cursor < buf.length) {
|
|
193
|
+
cursor++;
|
|
194
|
+
out.write("\x1b[C");
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (final === "H") {
|
|
198
|
+
// Home
|
|
199
|
+
if (cursor > 0) {
|
|
200
|
+
out.write(`\x1b[${cursor}D`);
|
|
201
|
+
cursor = 0;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
else if (final === "F") {
|
|
205
|
+
// End
|
|
206
|
+
if (cursor < buf.length) {
|
|
207
|
+
out.write(`\x1b[${buf.length - cursor}C`);
|
|
208
|
+
cursor = buf.length;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (final === "u") {
|
|
212
|
+
// Kitty keyboard protocol: ESC [ codepoint ; modifier u
|
|
213
|
+
const parts = seq.split(";");
|
|
214
|
+
const codepoint = parseInt(parts[0], 10);
|
|
215
|
+
const mod = parts.length > 1 ? parseInt(parts[1], 10) : 1;
|
|
216
|
+
const hasCmd = (mod - 1) & 8; // super/cmd bit
|
|
217
|
+
const hasCtrl = (mod - 1) & 4; // ctrl bit
|
|
218
|
+
if (codepoint === 127 && (hasCmd || hasCtrl)) {
|
|
219
|
+
// Cmd+Backspace / Ctrl+Backspace — delete to line start
|
|
220
|
+
if (cursor > 0) {
|
|
221
|
+
buf = buf.slice(cursor);
|
|
222
|
+
cursor = 0;
|
|
223
|
+
redraw();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// Ignore other sequences (up/down, etc.)
|
|
228
|
+
}
|
|
59
229
|
}
|
|
60
230
|
continue;
|
|
61
231
|
}
|
|
62
232
|
// Printable characters
|
|
63
233
|
if (code >= 32) {
|
|
64
|
-
buf
|
|
65
|
-
|
|
234
|
+
buf = buf.slice(0, cursor) + chunk[i] + buf.slice(cursor);
|
|
235
|
+
cursor++;
|
|
236
|
+
redraw();
|
|
66
237
|
}
|
|
67
238
|
}
|
|
68
239
|
};
|
|
69
240
|
process.stdin.on("data", onData);
|
|
70
241
|
});
|
|
71
242
|
}
|
|
243
|
+
/** Strip ANSI escape codes to get visible length */
|
|
244
|
+
function stripAnsi(str) {
|
|
245
|
+
return str.replace(/\x1b\[[0-9;]*m/g, "");
|
|
246
|
+
}
|
|
72
247
|
const THINKING_PHRASES = [
|
|
73
248
|
"Thinking...",
|
|
74
249
|
"Crunching numbers...",
|
package/dist/cli/commands.d.ts
CHANGED
|
@@ -14,3 +14,5 @@ export declare function showScore(): void;
|
|
|
14
14
|
export declare function runAdd(): Promise<void>;
|
|
15
15
|
export declare function runRemove(): Promise<void>;
|
|
16
16
|
export declare function showAlerts(): void;
|
|
17
|
+
export declare function showBills(days?: number): void;
|
|
18
|
+
export declare function showRecap(period?: string): void;
|
package/dist/cli/commands.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../db/connection.js";
|
|
3
|
-
import { getNetWorth, getTransactionsFiltered, getBudgetStatuses, getGoals, getCashFlowThisMonth, formatMoney as rawFormatMoney, categoryLabel, } from "../queries/index.js";
|
|
3
|
+
import { getNetWorth, getTransactionsFiltered, getBudgetStatuses, getGoals, getCashFlowThisMonth, compareSpending, getNetWorthTrend, formatMoney as rawFormatMoney, categoryLabel, } from "../queries/index.js";
|
|
4
4
|
import { getLatestScore, getAchievements, getMonthlySavings } from "../scoring/index.js";
|
|
5
5
|
import { generateAlerts } from "../alerts/index.js";
|
|
6
6
|
import { runDailySync } from "../daily-sync.js";
|
|
@@ -388,3 +388,138 @@ export function showAlerts() {
|
|
|
388
388
|
}
|
|
389
389
|
console.log();
|
|
390
390
|
}
|
|
391
|
+
export function showBills(days = 7) {
|
|
392
|
+
const db = getDb();
|
|
393
|
+
const now = new Date();
|
|
394
|
+
const todayDay = now.getDate();
|
|
395
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
396
|
+
const endDay = todayDay + days;
|
|
397
|
+
let bills = [];
|
|
398
|
+
if (endDay <= daysInMonth) {
|
|
399
|
+
bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ? ORDER BY day_of_month`).all(todayDay + 1, endDay);
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
// Wraparound into next month
|
|
403
|
+
const thisMonth = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ? ORDER BY day_of_month`).all(todayDay + 1, daysInMonth);
|
|
404
|
+
const nextMonth = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ? ORDER BY day_of_month`).all(endDay - daysInMonth);
|
|
405
|
+
bills = [...thisMonth, ...nextMonth];
|
|
406
|
+
}
|
|
407
|
+
if (bills.length === 0) {
|
|
408
|
+
console.log(`\nNo upcoming bills in the next ${days} days.`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
console.log(`\n${heading("Upcoming Bills")} ${dim(`next ${days} days`)}\n`);
|
|
412
|
+
const maxName = Math.max(...bills.map(b => b.name.length));
|
|
413
|
+
let total = 0;
|
|
414
|
+
for (const b of bills) {
|
|
415
|
+
// Calculate the actual date for this bill
|
|
416
|
+
let billDate;
|
|
417
|
+
if (b.day_of_month > todayDay) {
|
|
418
|
+
billDate = new Date(now.getFullYear(), now.getMonth(), b.day_of_month);
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
billDate = new Date(now.getFullYear(), now.getMonth() + 1, b.day_of_month);
|
|
422
|
+
}
|
|
423
|
+
const dateStr = billDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
424
|
+
console.log(` ${dim(dateStr.padEnd(8))}${b.name.padEnd(maxName + 2)}${rawFormatMoney(b.amount).padStart(10)}`);
|
|
425
|
+
total += b.amount;
|
|
426
|
+
}
|
|
427
|
+
console.log(`\n ${dim("Total due:".padEnd(maxName + 10))}${chalk.bold(rawFormatMoney(total))}`);
|
|
428
|
+
console.log();
|
|
429
|
+
}
|
|
430
|
+
export function showRecap(period = "last_month") {
|
|
431
|
+
const db = getDb();
|
|
432
|
+
const now = new Date();
|
|
433
|
+
const y = now.getFullYear();
|
|
434
|
+
const m = now.getMonth();
|
|
435
|
+
let start, end, label;
|
|
436
|
+
let prevStart, prevEnd;
|
|
437
|
+
if (period === "this_month") {
|
|
438
|
+
start = new Date(y, m, 1).toISOString().slice(0, 10);
|
|
439
|
+
end = now.toISOString().slice(0, 10);
|
|
440
|
+
label = now.toLocaleDateString("en-US", { month: "long", year: "numeric" }) + " (so far)";
|
|
441
|
+
prevStart = new Date(y, m - 1, 1).toISOString().slice(0, 10);
|
|
442
|
+
prevEnd = new Date(y, m, 0).toISOString().slice(0, 10);
|
|
443
|
+
}
|
|
444
|
+
else {
|
|
445
|
+
// last_month
|
|
446
|
+
start = new Date(y, m - 1, 1).toISOString().slice(0, 10);
|
|
447
|
+
end = new Date(y, m, 0).toISOString().slice(0, 10);
|
|
448
|
+
const lastMonth = new Date(y, m - 1, 1);
|
|
449
|
+
label = lastMonth.toLocaleDateString("en-US", { month: "long", year: "numeric" });
|
|
450
|
+
prevStart = new Date(y, m - 2, 1).toISOString().slice(0, 10);
|
|
451
|
+
prevEnd = new Date(y, m - 1, 0).toISOString().slice(0, 10);
|
|
452
|
+
}
|
|
453
|
+
// Spending this period
|
|
454
|
+
const spending = db.prepare(`SELECT SUM(amount) as total, COUNT(*) as count FROM transactions
|
|
455
|
+
WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
|
|
456
|
+
AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')`).get(start, end);
|
|
457
|
+
// Income this period
|
|
458
|
+
const income = db.prepare(`SELECT COALESCE(SUM(ABS(amount)), 0) as total FROM transactions
|
|
459
|
+
WHERE amount < 0 AND date BETWEEN ? AND ? AND pending = 0
|
|
460
|
+
AND category NOT IN ('TRANSFER_IN')`).get(start, end);
|
|
461
|
+
const totalSpent = spending.total || 0;
|
|
462
|
+
const txnCount = spending.count || 0;
|
|
463
|
+
if (txnCount === 0) {
|
|
464
|
+
console.log(`\nNo transaction data for ${label}.`);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
console.log(`\n${heading("Recap")} ${dim(label)}\n`);
|
|
468
|
+
// ── Spending summary with comparison ──
|
|
469
|
+
const cmp = compareSpending(db, prevStart, prevEnd, start, end);
|
|
470
|
+
let spendLine = ` Spent ${chalk.bold(rawFormatMoney(totalSpent))} across ${txnCount} transactions`;
|
|
471
|
+
if (cmp.period1Total > 0) {
|
|
472
|
+
const pct = Math.abs(cmp.pctChange);
|
|
473
|
+
const dir = cmp.pctChange <= 0 ? chalk.green(`${pct}% less`) : chalk.red(`${pct}% more`);
|
|
474
|
+
spendLine += ` — ${dir} than prior month`;
|
|
475
|
+
}
|
|
476
|
+
console.log(spendLine);
|
|
477
|
+
// ── Income ──
|
|
478
|
+
if (income.total > 0) {
|
|
479
|
+
const net = income.total - totalSpent;
|
|
480
|
+
const savingsRate = Math.round((net / income.total) * 100);
|
|
481
|
+
console.log(` Earned ${chalk.bold(rawFormatMoney(income.total))} Net: ${formatMoneyColored(net)} ${dim(`(${savingsRate}% savings rate)`)}`);
|
|
482
|
+
}
|
|
483
|
+
// ── Biggest movers ──
|
|
484
|
+
const movers = cmp.categories.filter(c => Math.abs(c.diff) >= 10).slice(0, 3);
|
|
485
|
+
if (movers.length > 0) {
|
|
486
|
+
console.log(`\n ${heading("Biggest Movers")}`);
|
|
487
|
+
for (const mv of movers) {
|
|
488
|
+
const arrow = mv.diff > 0 ? chalk.red("↑") : chalk.green("↓");
|
|
489
|
+
const diffStr = mv.diff > 0 ? chalk.red("+" + rawFormatMoney(mv.diff)) : chalk.green("-" + rawFormatMoney(Math.abs(mv.diff)));
|
|
490
|
+
console.log(` ${arrow} ${categoryLabel(mv.category).padEnd(18)} ${rawFormatMoney(mv.period2).padStart(10)} ${diffStr}`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
// ── Top categories ──
|
|
494
|
+
const topCats = db.prepare(`SELECT category, SUM(amount) as total FROM transactions
|
|
495
|
+
WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
|
|
496
|
+
AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')
|
|
497
|
+
GROUP BY category ORDER BY total DESC LIMIT 5`).all(start, end);
|
|
498
|
+
if (topCats.length > 0) {
|
|
499
|
+
console.log(`\n ${heading("Top Categories")}`);
|
|
500
|
+
for (const c of topCats) {
|
|
501
|
+
const pct = Math.round((c.total / totalSpent) * 100);
|
|
502
|
+
console.log(` ${categoryLabel(c.category).padEnd(18)} ${rawFormatMoney(c.total).padStart(10)} ${dim(`${pct}%`)}`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// ── Net worth change over the period ──
|
|
506
|
+
const nwTrend = getNetWorthTrend(db, 60);
|
|
507
|
+
const nwAtStart = nwTrend.find(d => d.date >= start);
|
|
508
|
+
const nwAtEnd = [...nwTrend].reverse().find(d => d.date <= end);
|
|
509
|
+
if (nwAtStart && nwAtEnd) {
|
|
510
|
+
const nwChange = nwAtEnd.net_worth - nwAtStart.net_worth;
|
|
511
|
+
const arrow = nwChange >= 0 ? chalk.green("↑") : chalk.red("↓");
|
|
512
|
+
console.log(`\n ${heading("Net Worth")}`);
|
|
513
|
+
console.log(` ${rawFormatMoney(nwAtStart.net_worth)} → ${chalk.bold(rawFormatMoney(nwAtEnd.net_worth))} ${arrow} ${formatMoneyColored(nwChange)}`);
|
|
514
|
+
}
|
|
515
|
+
// ── Goals progress ──
|
|
516
|
+
const goals = getGoals(db);
|
|
517
|
+
const activeGoals = goals.filter(g => g.progress_pct < 100);
|
|
518
|
+
if (activeGoals.length > 0) {
|
|
519
|
+
console.log(`\n ${heading("Goals")}`);
|
|
520
|
+
for (const g of activeGoals) {
|
|
521
|
+
console.log(` ${g.name.padEnd(20)} ${progressBar(g.progress_pct, 12)} ${dim(rawFormatMoney(g.current) + " / " + rawFormatMoney(g.target))}`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
console.log();
|
|
525
|
+
}
|
package/dist/cli/completions.js
CHANGED
|
@@ -45,12 +45,13 @@ const COMMANDS = [
|
|
|
45
45
|
{ name: "goals", desc: "Show financial goals" },
|
|
46
46
|
{ name: "score", desc: "Show daily financial score and streaks" },
|
|
47
47
|
{ name: "alerts", desc: "Show financial alerts" },
|
|
48
|
+
{ name: "bills", desc: "Show upcoming bills" },
|
|
49
|
+
{ name: "recap", desc: "Monthly spending recap" },
|
|
48
50
|
{ name: "export", desc: "Export data to a backup file" },
|
|
49
51
|
{ name: "import", desc: "Restore data from a backup file" },
|
|
50
52
|
{ name: "billing", desc: "Manage your Ray subscription" },
|
|
51
53
|
{ name: "update", desc: "Update Ray to the latest version" },
|
|
52
54
|
{ name: "doctor", desc: "Check system health" },
|
|
53
|
-
{ name: "completions", desc: "Install shell completions" },
|
|
54
55
|
];
|
|
55
56
|
const SPENDING_PERIODS = ["this_month", "last_month", "last_30", "last_90"];
|
|
56
57
|
function generateZsh() {
|
package/dist/cli/doctor.js
CHANGED
|
@@ -137,7 +137,7 @@ export async function runDoctor() {
|
|
|
137
137
|
checks.push({ label: "Shell completions", status: "ok", detail: completionPath });
|
|
138
138
|
}
|
|
139
139
|
else {
|
|
140
|
-
checks.push({ label: "Shell completions", status: "warn", detail:
|
|
140
|
+
checks.push({ label: "Shell completions", status: "warn", detail: "Not installed" });
|
|
141
141
|
}
|
|
142
142
|
// ── Node version ──
|
|
143
143
|
const nodeVersion = process.version;
|
package/dist/cli/index.js
CHANGED
|
@@ -144,6 +144,24 @@ program
|
|
|
144
144
|
const { showAlerts } = await import("./commands.js");
|
|
145
145
|
showAlerts();
|
|
146
146
|
});
|
|
147
|
+
program
|
|
148
|
+
.command("bills")
|
|
149
|
+
.description("Show upcoming bills")
|
|
150
|
+
.option("-d, --days <number>", "Number of days ahead", "7")
|
|
151
|
+
.action(async (opts) => {
|
|
152
|
+
ensureConfigured();
|
|
153
|
+
const { showBills } = await import("./commands.js");
|
|
154
|
+
showBills(Number(opts.days));
|
|
155
|
+
});
|
|
156
|
+
program
|
|
157
|
+
.command("recap")
|
|
158
|
+
.description("Monthly spending recap")
|
|
159
|
+
.argument("[period]", "Period: this_month, last_month", "last_month")
|
|
160
|
+
.action(async (period) => {
|
|
161
|
+
ensureConfigured();
|
|
162
|
+
const { showRecap } = await import("./commands.js");
|
|
163
|
+
showRecap(period);
|
|
164
|
+
});
|
|
147
165
|
program
|
|
148
166
|
.command("export")
|
|
149
167
|
.description("Export user data (goals, budgets, memories, context) to a backup file")
|
|
@@ -168,7 +186,7 @@ program
|
|
|
168
186
|
.action(async () => {
|
|
169
187
|
ensureConfigured();
|
|
170
188
|
if (!useManaged()) {
|
|
171
|
-
console.log("You're using
|
|
189
|
+
console.log("You're using your own keys. No subscription to manage.");
|
|
172
190
|
return;
|
|
173
191
|
}
|
|
174
192
|
const open = (await import("open")).default;
|
|
@@ -217,13 +235,6 @@ program
|
|
|
217
235
|
const { seedDemoDb } = await import("../demo/seed.js");
|
|
218
236
|
seedDemoDb(demoPath);
|
|
219
237
|
});
|
|
220
|
-
program
|
|
221
|
-
.command("completions")
|
|
222
|
-
.description("Install shell completions")
|
|
223
|
-
.action(async () => {
|
|
224
|
-
const { installCompletions } = await import("./completions.js");
|
|
225
|
-
installCompletions();
|
|
226
|
-
});
|
|
227
238
|
function ensureConfigured() {
|
|
228
239
|
if (isDemoMode)
|
|
229
240
|
return;
|
|
@@ -248,13 +259,14 @@ program.configureHelp({
|
|
|
248
259
|
{ name: "goals", desc: "Show financial goals" },
|
|
249
260
|
{ name: "score", desc: "Show daily financial score and streaks" },
|
|
250
261
|
{ name: "alerts", desc: "Show financial alerts" },
|
|
262
|
+
{ name: "bills", desc: "Show upcoming bills" },
|
|
263
|
+
{ name: "recap", desc: "Monthly spending recap" },
|
|
251
264
|
{ name: "export", desc: "Export data to a backup file" },
|
|
252
265
|
{ name: "import", desc: "Restore data from a backup file" },
|
|
253
266
|
{ name: "billing", desc: "Manage your Ray subscription" },
|
|
254
267
|
{ name: "update", desc: "Update Ray to the latest version" },
|
|
255
268
|
{ name: "doctor", desc: "Check system health" },
|
|
256
269
|
{ name: "demo", desc: "Seed a demo database with fake data" },
|
|
257
|
-
{ name: "completions", desc: "Install shell completions" },
|
|
258
270
|
]),
|
|
259
271
|
});
|
|
260
272
|
import("./updater.js").then(m => m.checkForUpdate(version)).catch(() => { });
|
package/dist/cli/setup.js
CHANGED
|
@@ -31,7 +31,7 @@ export async function runSetup() {
|
|
|
31
31
|
message: "How would you like to set up Ray?",
|
|
32
32
|
choices: [
|
|
33
33
|
{ name: "Quick setup — we handle the API keys, your data stays local", value: "managed" },
|
|
34
|
-
{ name: "
|
|
34
|
+
{ name: "Bring your own keys — use your own Anthropic and Plaid credentials", value: "selfhosted" },
|
|
35
35
|
],
|
|
36
36
|
}]);
|
|
37
37
|
let canLink = false;
|
package/dist/daily-sync.js
CHANGED
|
@@ -145,7 +145,7 @@ export async function runDailySync(db) {
|
|
|
145
145
|
? db.prepare(`UPDATE transactions SET category = ?, subcategory = ? WHERE ${rule.match_field} LIKE ? AND category != ?`).run(rule.target_category, rule.target_subcategory, rule.match_pattern, rule.target_category)
|
|
146
146
|
: db.prepare(`UPDATE transactions SET category = ? WHERE ${rule.match_field} LIKE ? AND category != ?`).run(rule.target_category, rule.match_pattern, rule.target_category);
|
|
147
147
|
if (result.changes > 0) {
|
|
148
|
-
console.log(` Recategorized ${result.changes} txn(s): ${rule.label}`);
|
|
148
|
+
console.log(` Recategorized ${result.changes} txn(s): ${rule.label || rule.match_pattern}`);
|
|
149
149
|
totalRecat += result.changes;
|
|
150
150
|
}
|
|
151
151
|
}
|
package/dist/queries/index.js
CHANGED
|
@@ -406,6 +406,15 @@ export function categoryLabel(cat) {
|
|
|
406
406
|
GOVERNMENT_AND_NON_PROFIT: "Gov/Nonprofit",
|
|
407
407
|
MEDICAL: "Medical",
|
|
408
408
|
BANK_FEES: "Bank Fees",
|
|
409
|
+
EDUCATION: "Education",
|
|
410
|
+
INSURANCE: "Insurance",
|
|
411
|
+
BUSINESS: "Business",
|
|
412
|
+
INCOME: "Income",
|
|
413
|
+
TRANSFER_IN: "Transfer In",
|
|
414
|
+
TRANSFER_OUT: "Transfer Out",
|
|
415
|
+
HOME_IMPROVEMENT: "Home Improvement",
|
|
416
|
+
TRAVEL: "Travel",
|
|
417
|
+
OTHER: "Other",
|
|
409
418
|
};
|
|
410
|
-
return labels[cat] || cat;
|
|
419
|
+
return labels[cat] || cat.split("_").map(w => w.charAt(0) + w.slice(1).toLowerCase()).join(" ");
|
|
411
420
|
}
|
package/dist/server.js
CHANGED
|
@@ -75,7 +75,15 @@ export function startLinkServer() {
|
|
|
75
75
|
}
|
|
76
76
|
catch (error) {
|
|
77
77
|
console.error("Link token error:", error.message);
|
|
78
|
-
|
|
78
|
+
const plaidStatus = error?.response?.status;
|
|
79
|
+
if (plaidStatus === 400 || plaidStatus === 401 || plaidStatus === 403) {
|
|
80
|
+
res.status(500).json({
|
|
81
|
+
error: "Plaid credentials error — make sure you're using production (not sandbox) keys. Check PLAID_CLIENT_ID and PLAID_SECRET in ~/.ray/config.json.",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
res.status(500).json({ error: "Failed to create link token: " + (error.message || "unknown error") });
|
|
86
|
+
}
|
|
79
87
|
}
|
|
80
88
|
});
|
|
81
89
|
// Exchange public token
|