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
package/src/ai/redactor.ts
CHANGED
|
@@ -82,12 +82,24 @@ function buildRedactions(): RedactionEntry[] {
|
|
|
82
82
|
return entries;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
// Patterns for numeric PII that should never reach the API
|
|
86
|
+
const NUMERIC_PII_PATTERNS: [RegExp, string][] = [
|
|
87
|
+
[/\b\d{3}-\d{2}-\d{4}\b/g, "[SSN]"], // SSN: 123-45-6789
|
|
88
|
+
[/\b\d{9}\b(?=\s|$|[,.])/g, "[SSN]"], // SSN without dashes
|
|
89
|
+
[/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, "[CARD]"], // Credit card
|
|
90
|
+
[/\b\d{9,12}\b(?=\s|$|[,.])/g, "[ACCT]"], // Account/routing numbers
|
|
91
|
+
];
|
|
92
|
+
|
|
85
93
|
export function redact(text: string): string {
|
|
86
94
|
const redactions = buildRedactions();
|
|
87
95
|
let result = text;
|
|
88
96
|
for (const { real, token } of redactions) {
|
|
89
97
|
result = result.replaceAll(real, token);
|
|
90
98
|
}
|
|
99
|
+
// Redact numeric PII patterns
|
|
100
|
+
for (const [pattern, replacement] of NUMERIC_PII_PATTERNS) {
|
|
101
|
+
result = result.replace(pattern, replacement);
|
|
102
|
+
}
|
|
91
103
|
return result;
|
|
92
104
|
}
|
|
93
105
|
|
package/src/ai/system-prompt.ts
CHANGED
|
@@ -32,9 +32,9 @@ Today is ${dateStr}.
|
|
|
32
32
|
4. End with what to do, not just what happened. A good CFO always has a next step.
|
|
33
33
|
|
|
34
34
|
## Formatting (terminal output)
|
|
35
|
-
-
|
|
35
|
+
- Use markdown sparingly: **bold** for key numbers or emphasis, ## for section headers. No backticks or code blocks.
|
|
36
36
|
- Use line breaks, dashes, and simple alignment for structure.
|
|
37
|
-
- Use
|
|
37
|
+
- Use bullet points (- ) for lists.
|
|
38
38
|
|
|
39
39
|
## Tools
|
|
40
40
|
- Always use tools to look up current data. Never guess balances, spending, or dates.
|
package/src/ai/tools.ts
CHANGED
|
@@ -704,6 +704,10 @@ export async function executeTool(db: Database.Database, toolName: string, toolI
|
|
|
704
704
|
}
|
|
705
705
|
|
|
706
706
|
case "add_recat_rule": {
|
|
707
|
+
const allowedFields = ["name", "merchant_name", "category", "subcategory"];
|
|
708
|
+
if (!allowedFields.includes(toolInput.match_field)) {
|
|
709
|
+
return `Invalid match_field "${toolInput.match_field}". Must be one of: ${allowedFields.join(", ")}`;
|
|
710
|
+
}
|
|
707
711
|
db.prepare(
|
|
708
712
|
`INSERT INTO recategorization_rules (match_field, match_pattern, target_category, target_subcategory, label) VALUES (?, ?, ?, ?, ?)`
|
|
709
713
|
).run(toolInput.match_field, toolInput.match_pattern, toolInput.target_category, toolInput.target_subcategory || null, toolInput.label || null);
|
package/src/cli/backup.ts
CHANGED
|
@@ -67,16 +67,23 @@ export function runImport(inputPath: string): void {
|
|
|
67
67
|
writeContext(backup.context);
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Restore memories
|
|
71
|
-
const insertMemory = db.prepare(
|
|
70
|
+
// Restore memories (skip exact duplicates)
|
|
71
|
+
const insertMemory = db.prepare(
|
|
72
|
+
"INSERT INTO memories (content, category) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM memories WHERE content = ? AND category = ?)"
|
|
73
|
+
);
|
|
72
74
|
for (const m of backup.memories) {
|
|
73
|
-
insertMemory.run(m.content, m.category);
|
|
75
|
+
insertMemory.run(m.content, m.category, m.content, m.category);
|
|
74
76
|
}
|
|
75
77
|
|
|
76
|
-
// Restore goals
|
|
77
|
-
const
|
|
78
|
+
// Restore goals (skip if name already exists)
|
|
79
|
+
const existingGoal = db.prepare("SELECT 1 FROM goals WHERE name = ?");
|
|
80
|
+
const insertGoal = db.prepare(
|
|
81
|
+
"INSERT INTO goals (name, target_amount, current_amount, deadline, status) VALUES (?, ?, ?, ?, ?)"
|
|
82
|
+
);
|
|
78
83
|
for (const g of backup.goals) {
|
|
79
|
-
|
|
84
|
+
if (!existingGoal.get(g.name)) {
|
|
85
|
+
insertGoal.run(g.name, g.target_amount, g.current_amount, g.deadline, g.status);
|
|
86
|
+
}
|
|
80
87
|
}
|
|
81
88
|
|
|
82
89
|
// Restore budgets
|
|
@@ -87,10 +94,13 @@ export function runImport(inputPath: string): void {
|
|
|
87
94
|
insertBudget.run(b.category, b.monthly_limit, b.period);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
|
-
// Restore recat rules
|
|
97
|
+
// Restore recat rules (skip exact duplicates)
|
|
98
|
+
const existingRule = db.prepare("SELECT 1 FROM recategorization_rules WHERE match_field = ? AND match_pattern = ? AND target_category = ?");
|
|
91
99
|
const insertRule = db.prepare("INSERT INTO recategorization_rules (match_field, match_pattern, target_category, target_subcategory, label) VALUES (?, ?, ?, ?, ?)");
|
|
92
100
|
for (const r of backup.recat_rules) {
|
|
93
|
-
|
|
101
|
+
if (!existingRule.get(r.match_field, r.match_pattern, r.target_category)) {
|
|
102
|
+
insertRule.run(r.match_field, r.match_pattern, r.target_category, r.target_subcategory, r.label);
|
|
103
|
+
}
|
|
94
104
|
}
|
|
95
105
|
|
|
96
106
|
// Restore settings
|
|
@@ -99,10 +109,13 @@ export function runImport(inputPath: string): void {
|
|
|
99
109
|
insertSetting.run(s.key, s.value);
|
|
100
110
|
}
|
|
101
111
|
|
|
102
|
-
// Restore milestones
|
|
112
|
+
// Restore milestones (skip if name already exists)
|
|
113
|
+
const existingMilestone = db.prepare("SELECT 1 FROM milestones WHERE name = ?");
|
|
103
114
|
const insertMilestone = db.prepare("INSERT INTO milestones (name, target_date, monthly_savings, description) VALUES (?, ?, ?, ?)");
|
|
104
115
|
for (const m of backup.milestones) {
|
|
105
|
-
|
|
116
|
+
if (!existingMilestone.get(m.name)) {
|
|
117
|
+
insertMilestone.run(m.name, m.target_date, m.monthly_savings, m.description);
|
|
118
|
+
}
|
|
106
119
|
}
|
|
107
120
|
|
|
108
121
|
console.log(chalk.green(`\nBackup restored from ${inputPath}`));
|
package/src/cli/chat.ts
CHANGED
|
@@ -1,29 +1,106 @@
|
|
|
1
|
-
import * as readline from "readline/promises";
|
|
2
1
|
import chalk from "chalk";
|
|
3
2
|
import { getDb } from "../db/connection.js";
|
|
4
3
|
import { handleMessage } from "../ai/agent.js";
|
|
5
4
|
import { config } from "../config.js";
|
|
6
5
|
import { isContextEmpty } from "../ai/context.js";
|
|
7
6
|
import { cliBriefing } from "../ai/insights.js";
|
|
8
|
-
import { DISCLAIMER } from "./format.js";
|
|
7
|
+
import { banner, DISCLAIMER, formatResponse } from "./format.js";
|
|
8
|
+
|
|
9
|
+
/** Raw-mode line reader that renders content below the cursor while waiting for input */
|
|
10
|
+
function rawReadLine(prompt: string, belowLines: string[]): Promise<string> {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
let buf = "";
|
|
13
|
+
const out = process.stdout;
|
|
14
|
+
|
|
15
|
+
// Render: prompt on current line, then content below, then restore cursor
|
|
16
|
+
out.write(prompt);
|
|
17
|
+
if (belowLines.length > 0) {
|
|
18
|
+
// Save cursor, render below, restore
|
|
19
|
+
out.write("\x1b[s");
|
|
20
|
+
out.write("\n" + belowLines.join("\n"));
|
|
21
|
+
out.write("\x1b[u");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
process.stdin.setRawMode!(true);
|
|
25
|
+
process.stdin.resume();
|
|
26
|
+
process.stdin.setEncoding("utf8");
|
|
27
|
+
|
|
28
|
+
const cleanup = () => {
|
|
29
|
+
process.stdin.setRawMode!(false);
|
|
30
|
+
process.stdin.removeListener("data", onData);
|
|
31
|
+
process.stdin.pause();
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const onData = (chunk: string) => {
|
|
35
|
+
for (let i = 0; i < chunk.length; i++) {
|
|
36
|
+
const code = chunk.charCodeAt(i);
|
|
37
|
+
|
|
38
|
+
// Ctrl+C / Ctrl+D
|
|
39
|
+
if (code === 3 || code === 4) {
|
|
40
|
+
cleanup();
|
|
41
|
+
out.write("\n");
|
|
42
|
+
resolve("\x03");
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Enter
|
|
47
|
+
if (code === 13) {
|
|
48
|
+
cleanup();
|
|
49
|
+
// Move past the below-content lines, then newline
|
|
50
|
+
for (let j = 0; j < belowLines.length; j++) out.write("\x1b[1B");
|
|
51
|
+
out.write("\n");
|
|
52
|
+
resolve(buf);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Backspace
|
|
57
|
+
if (code === 127 || code === 8) {
|
|
58
|
+
if (buf.length > 0) {
|
|
59
|
+
buf = buf.slice(0, -1);
|
|
60
|
+
out.write("\b \b");
|
|
61
|
+
}
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Skip escape sequences (arrow keys etc.)
|
|
66
|
+
if (code === 27) {
|
|
67
|
+
if (i + 1 < chunk.length && chunk[i + 1] === "[") {
|
|
68
|
+
i += 2;
|
|
69
|
+
while (i < chunk.length && chunk.charCodeAt(i) < 64) i++;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Printable characters
|
|
75
|
+
if (code >= 32) {
|
|
76
|
+
buf += chunk[i];
|
|
77
|
+
out.write(chunk[i]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
process.stdin.on("data", onData);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
9
85
|
|
|
10
86
|
export async function startChat(): Promise<void> {
|
|
11
87
|
const ora = (await import("ora")).default;
|
|
12
88
|
const db = getDb();
|
|
13
89
|
|
|
14
|
-
// Show
|
|
90
|
+
// Show logo + briefing
|
|
91
|
+
console.log("");
|
|
92
|
+
console.log(banner());
|
|
93
|
+
console.log("");
|
|
94
|
+
|
|
15
95
|
const briefing = cliBriefing(db);
|
|
16
96
|
if (briefing) {
|
|
17
97
|
const now = new Date();
|
|
18
|
-
const timeStr = now.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" });
|
|
19
|
-
console.log("");
|
|
98
|
+
const timeStr = now.toLocaleDateString("en-US", { weekday: "long", month: "short", day: "numeric" }).toLowerCase();
|
|
20
99
|
console.log(chalk.dim(` ${timeStr}`));
|
|
21
100
|
console.log("");
|
|
22
101
|
console.log(briefing);
|
|
23
|
-
console.log("");
|
|
24
|
-
console.log(chalk.dim("─".repeat(process.stdout.columns || 80)));
|
|
25
102
|
} else {
|
|
26
|
-
console.log(chalk.bold(
|
|
103
|
+
console.log(chalk.bold(`ray`) + chalk.dim(` — ${config.userName}`));
|
|
27
104
|
}
|
|
28
105
|
console.log("");
|
|
29
106
|
|
|
@@ -60,46 +137,83 @@ export async function startChat(): Promise<void> {
|
|
|
60
137
|
}
|
|
61
138
|
}
|
|
62
139
|
|
|
63
|
-
const rl = readline.createInterface({
|
|
64
|
-
input: process.stdin,
|
|
65
|
-
output: process.stdout,
|
|
66
|
-
});
|
|
67
|
-
|
|
68
140
|
const shutdown = () => {
|
|
69
141
|
console.log(chalk.dim("\nGoodbye!"));
|
|
70
|
-
rl.close();
|
|
71
142
|
process.exit(0);
|
|
72
143
|
};
|
|
73
144
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
145
|
+
const hints = [
|
|
146
|
+
"try: how am i doing this month?",
|
|
147
|
+
"try: where's my money going?",
|
|
148
|
+
"try: what bills are coming up?",
|
|
149
|
+
"try: help me save more",
|
|
150
|
+
"try: am i on track for my goals?",
|
|
151
|
+
"try: any unusual spending lately?",
|
|
152
|
+
"try: what should i focus on?",
|
|
153
|
+
"try: compare this month to last month",
|
|
154
|
+
"try: set a budget for dining out",
|
|
155
|
+
"try: how much did i spend on groceries?",
|
|
156
|
+
];
|
|
157
|
+
let hintIdx = Math.floor(Math.random() * hints.length);
|
|
158
|
+
|
|
159
|
+
const getFooterText = () => {
|
|
160
|
+
const lastSync = db.prepare(`SELECT MAX(updated_at) as ts FROM accounts`).get() as { ts: string | null };
|
|
161
|
+
let syncStr = "";
|
|
162
|
+
if (lastSync.ts) {
|
|
163
|
+
const diffMs = Date.now() - new Date(lastSync.ts + "Z").getTime();
|
|
164
|
+
const mins = Math.floor(diffMs / 60000);
|
|
165
|
+
if (mins < 1) syncStr = "synced just now";
|
|
166
|
+
else if (mins < 60) syncStr = `synced ${mins}m ago`;
|
|
167
|
+
else if (mins < 1440) syncStr = `synced ${Math.floor(mins / 60)}h ago`;
|
|
168
|
+
else syncStr = `synced ${Math.floor(mins / 1440)}d ago`;
|
|
169
|
+
}
|
|
170
|
+
const parts = ["ray"];
|
|
171
|
+
if (syncStr) parts.push(syncStr);
|
|
172
|
+
parts.push(hints[hintIdx]);
|
|
173
|
+
hintIdx = (hintIdx + 1) % hints.length;
|
|
174
|
+
return parts.join(" · ");
|
|
175
|
+
};
|
|
90
176
|
|
|
91
|
-
|
|
177
|
+
while (true) {
|
|
178
|
+
const cols = process.stdout.columns || 80;
|
|
179
|
+
const rule = chalk.dim("─".repeat(cols));
|
|
180
|
+
const footerText = chalk.dim(` ${getFooterText()}`);
|
|
92
181
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
182
|
+
// Ensure room below for top rule + prompt + bottom rule + footer (3 lines below start)
|
|
183
|
+
process.stdout.write("\n\n\n");
|
|
184
|
+
process.stdout.write("\x1b[3A\r");
|
|
185
|
+
|
|
186
|
+
// Print top rule, then prompt with bottom rule + footer rendered below
|
|
187
|
+
console.log(rule);
|
|
188
|
+
const input = await rawReadLine(chalk.dim("❯ "), [rule, footerText]);
|
|
189
|
+
|
|
190
|
+
const trimmed = input.trim();
|
|
191
|
+
|
|
192
|
+
if (!trimmed) continue;
|
|
193
|
+
|
|
194
|
+
// Replace prompt frame with gray-background user message
|
|
195
|
+
// Move up 4 lines (footer, bottom rule, prompt, top rule) and clear them
|
|
196
|
+
process.stdout.write("\x1b[4A\r");
|
|
197
|
+
for (let i = 0; i < 4; i++) process.stdout.write("\x1b[2K\x1b[1B");
|
|
198
|
+
process.stdout.write("\x1b[4A\r");
|
|
199
|
+
// Print user message with gray background, padded to full width
|
|
200
|
+
const msgText = `❯ ${trimmed}`;
|
|
201
|
+
const pad = Math.max(0, cols - msgText.length);
|
|
202
|
+
console.log(chalk.bgGray.white(msgText + " ".repeat(pad)));
|
|
203
|
+
if (trimmed === "\x03" || trimmed === "/quit" || trimmed === "/exit" || trimmed === "/q") {
|
|
204
|
+
shutdown();
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const spinner = ora({ text: "Thinking...", color: "cyan", discardStdin: false }).start();
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
const response = await handleMessage(db, trimmed);
|
|
212
|
+
spinner.stop();
|
|
213
|
+
console.log(`\n${formatResponse(response)}\n`);
|
|
214
|
+
} catch (err: any) {
|
|
215
|
+
spinner.stop();
|
|
216
|
+
console.error(chalk.red("Error: " + err.message));
|
|
101
217
|
}
|
|
102
|
-
} finally {
|
|
103
|
-
rl.close();
|
|
104
218
|
}
|
|
105
219
|
}
|
package/src/cli/format.ts
CHANGED
|
@@ -144,6 +144,37 @@ export function helpScreen(
|
|
|
144
144
|
return sections.join("\n");
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
/** Colorize AI response text for the terminal */
|
|
148
|
+
export function formatResponse(text: string): string {
|
|
149
|
+
return text
|
|
150
|
+
.split("\n")
|
|
151
|
+
.map((line) => {
|
|
152
|
+
// Section headers: ## Header or ### Header
|
|
153
|
+
if (/^#{1,3}\s+/.test(line)) {
|
|
154
|
+
return chalk.bold(line.replace(/^#{1,3}\s+/, ""));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Bold: **text**
|
|
158
|
+
line = line.replace(/\*\*(.+?)\*\*/g, (_, t) => chalk.bold(t));
|
|
159
|
+
|
|
160
|
+
// Money amounts: $1,234 or $1,234.56 or -$500
|
|
161
|
+
line = line.replace(/-?\$[\d,]+(?:\.\d{1,2})?/g, (m) => {
|
|
162
|
+
return m.startsWith("-") ? chalk.red(m) : chalk.green(m);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Percentages
|
|
166
|
+
line = line.replace(/(\d+(?:\.\d+)?%)/g, (m) => chalk.yellow(m));
|
|
167
|
+
|
|
168
|
+
// Bullet points
|
|
169
|
+
if (/^\s*[-•]\s/.test(line)) {
|
|
170
|
+
line = line.replace(/^(\s*)([-•])(\s)/, (_, sp, b, s) => sp + chalk.dim(b) + s);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return line;
|
|
174
|
+
})
|
|
175
|
+
.join("\n");
|
|
176
|
+
}
|
|
177
|
+
|
|
147
178
|
export const DISCLAIMER =
|
|
148
179
|
"Ray is an AI tool, not a licensed financial advisor. Output is informational, " +
|
|
149
180
|
"may be inaccurate, and does not constitute financial advice.";
|
package/src/cli/index.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
+
import { createRequire } from "module";
|
|
3
4
|
import { config, isConfigured, useManaged, RAY_PROXY_BASE } from "../config.js";
|
|
4
5
|
import { helpScreen } from "./format.js";
|
|
5
6
|
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const { version } = require("../../package.json");
|
|
9
|
+
|
|
6
10
|
const program = new Command();
|
|
7
11
|
|
|
8
12
|
program
|
|
9
13
|
.name("ray")
|
|
10
14
|
.description("Personal finance AI assistant")
|
|
11
|
-
.version(
|
|
15
|
+
.version(version)
|
|
12
16
|
.addHelpCommand(false)
|
|
13
17
|
.action(async () => {
|
|
14
18
|
if (!isConfigured()) {
|
|
@@ -158,7 +162,13 @@ program
|
|
|
158
162
|
},
|
|
159
163
|
});
|
|
160
164
|
const { url } = await resp.json() as { url: string };
|
|
161
|
-
|
|
165
|
+
// Only open URLs from trusted domains
|
|
166
|
+
const parsed = new URL(url);
|
|
167
|
+
if (!parsed.hostname.endsWith("stripe.com") && !parsed.hostname.endsWith("rayfinance.app")) {
|
|
168
|
+
console.error("Unexpected billing URL. Visit https://rayfinance.app/billing");
|
|
169
|
+
} else {
|
|
170
|
+
await open(url);
|
|
171
|
+
}
|
|
162
172
|
} catch {
|
|
163
173
|
console.error("Could not open billing portal. Visit https://rayfinance.app/billing");
|
|
164
174
|
}
|
package/src/cli/setup.ts
CHANGED
|
@@ -61,7 +61,12 @@ export async function runSetup(): Promise<void> {
|
|
|
61
61
|
headers: { "content-type": "application/json" },
|
|
62
62
|
});
|
|
63
63
|
const { url } = await resp.json() as { url: string };
|
|
64
|
-
|
|
64
|
+
const parsed = new URL(url);
|
|
65
|
+
if (!parsed.hostname.endsWith("stripe.com") && !parsed.hostname.endsWith("rayfinance.app")) {
|
|
66
|
+
console.log(dim(` Unexpected checkout URL. Visit https://rayfinance.app to subscribe.\n`));
|
|
67
|
+
} else {
|
|
68
|
+
await open(url);
|
|
69
|
+
}
|
|
65
70
|
} catch {
|
|
66
71
|
console.log(dim(` Could not open checkout automatically.`));
|
|
67
72
|
console.log(dim(` Re-run ray setup to try again.\n`));
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3-multiple-ciphers";
|
|
3
|
+
import { migrate } from "./db/schema.js";
|
|
4
|
+
import { encryptPlaidToken } from "./db/encryption.js";
|
|
5
|
+
|
|
6
|
+
// Mock Plaid sync functions
|
|
7
|
+
vi.mock("./plaid/sync.js", () => ({
|
|
8
|
+
syncBalances: vi.fn().mockResolvedValue(3),
|
|
9
|
+
syncTransactions: vi.fn().mockResolvedValue({ added: 5, modified: 0, removed: 0 }),
|
|
10
|
+
syncInvestments: vi.fn().mockResolvedValue({ holdings: 2, securities: 2 }),
|
|
11
|
+
syncLiabilities: vi.fn().mockResolvedValue(undefined),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
// Mock scoring
|
|
15
|
+
vi.mock("./scoring/index.js", () => ({
|
|
16
|
+
calculateDailyScore: vi.fn().mockReturnValue({ score: 75 }),
|
|
17
|
+
checkAchievements: vi.fn().mockReturnValue([]),
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
// Mock config
|
|
21
|
+
vi.mock("./config.js", () => ({
|
|
22
|
+
config: { plaidTokenSecret: "test-secret" },
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
import { runDailySync } from "./daily-sync.js";
|
|
26
|
+
import { syncBalances, syncTransactions } from "./plaid/sync.js";
|
|
27
|
+
|
|
28
|
+
type DB = InstanceType<typeof Database>;
|
|
29
|
+
|
|
30
|
+
function createTestDb(): DB {
|
|
31
|
+
const db = new Database(":memory:");
|
|
32
|
+
db.pragma("foreign_keys = ON");
|
|
33
|
+
migrate(db);
|
|
34
|
+
return db;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function seedInstitution(db: DB, opts: { id: string; token: string; products: string[]; name?: string }) {
|
|
38
|
+
db.prepare(`INSERT INTO institutions (item_id, access_token, name, products) VALUES (?, ?, ?, ?)`)
|
|
39
|
+
.run(opts.id, opts.token, opts.name || "Test Bank", JSON.stringify(opts.products));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe("runDailySync", () => {
|
|
43
|
+
let db: DB;
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
db = createTestDb();
|
|
47
|
+
vi.clearAllMocks();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("returns early with no institutions", async () => {
|
|
51
|
+
await runDailySync(db);
|
|
52
|
+
expect(syncBalances).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("skips manual institutions", async () => {
|
|
56
|
+
seedInstitution(db, { id: "i1", token: "manual", products: ["transactions"] });
|
|
57
|
+
await runDailySync(db);
|
|
58
|
+
expect(syncBalances).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("skips institutions with bad encrypted token", async () => {
|
|
62
|
+
seedInstitution(db, { id: "i1", token: "not:valid:encrypted:data", products: ["transactions"] });
|
|
63
|
+
// Should not throw — logs error and continues
|
|
64
|
+
await runDailySync(db);
|
|
65
|
+
expect(syncBalances).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("syncs institution with valid encrypted token", async () => {
|
|
69
|
+
const encrypted = encryptPlaidToken("access-sandbox-123", "test-secret");
|
|
70
|
+
seedInstitution(db, { id: "i1", token: encrypted, products: ["transactions"] });
|
|
71
|
+
await runDailySync(db);
|
|
72
|
+
expect(syncBalances).toHaveBeenCalledTimes(1);
|
|
73
|
+
expect(syncTransactions).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("writes net worth snapshot", async () => {
|
|
77
|
+
// Seed accounts so net worth is computed
|
|
78
|
+
db.prepare(`INSERT INTO institutions (item_id, access_token, name, products) VALUES (?, 'manual', ?, '[]')`)
|
|
79
|
+
.run("i1", "Bank");
|
|
80
|
+
db.prepare(`INSERT INTO accounts (account_id, item_id, name, type, current_balance) VALUES (?, ?, ?, ?, ?)`)
|
|
81
|
+
.run("a1", "i1", "Checking", "depository", 5000);
|
|
82
|
+
db.prepare(`INSERT INTO accounts (account_id, item_id, name, type, current_balance) VALUES (?, ?, ?, ?, ?)`)
|
|
83
|
+
.run("a2", "i1", "CC", "credit", 1000);
|
|
84
|
+
|
|
85
|
+
await runDailySync(db);
|
|
86
|
+
|
|
87
|
+
const row = db.prepare(`SELECT * FROM net_worth_history`).get() as any;
|
|
88
|
+
expect(row.total_assets).toBe(5000);
|
|
89
|
+
expect(row.total_liabilities).toBe(1000);
|
|
90
|
+
expect(row.net_worth).toBe(4000);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("upserts net worth on same day", async () => {
|
|
94
|
+
db.prepare(`INSERT INTO institutions (item_id, access_token, name, products) VALUES (?, 'manual', ?, '[]')`)
|
|
95
|
+
.run("i1", "Bank");
|
|
96
|
+
db.prepare(`INSERT INTO accounts (account_id, item_id, name, type, current_balance) VALUES (?, ?, ?, ?, ?)`)
|
|
97
|
+
.run("a1", "i1", "Checking", "depository", 5000);
|
|
98
|
+
|
|
99
|
+
await runDailySync(db);
|
|
100
|
+
// Update balance and sync again
|
|
101
|
+
db.prepare(`UPDATE accounts SET current_balance = 6000 WHERE account_id = 'a1'`).run();
|
|
102
|
+
await runDailySync(db);
|
|
103
|
+
|
|
104
|
+
const rows = db.prepare(`SELECT * FROM net_worth_history`).all();
|
|
105
|
+
expect(rows.length).toBe(1); // upsert, not duplicate
|
|
106
|
+
expect((rows[0] as any).net_worth).toBe(6000);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("recategorization rules", () => {
|
|
111
|
+
let db: DB;
|
|
112
|
+
|
|
113
|
+
beforeEach(() => {
|
|
114
|
+
db = createTestDb();
|
|
115
|
+
vi.clearAllMocks();
|
|
116
|
+
// Need at least a manual institution so sync reaches recat logic
|
|
117
|
+
db.prepare(`INSERT INTO institutions (item_id, access_token, name, products) VALUES (?, 'manual', ?, '[]')`)
|
|
118
|
+
.run("i1", "Bank");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("applies matching rules", async () => {
|
|
122
|
+
db.prepare(`INSERT INTO accounts (account_id, item_id, name, type, current_balance) VALUES (?, ?, ?, ?, ?)`)
|
|
123
|
+
.run("a1", "i1", "Checking", "depository", 1000);
|
|
124
|
+
db.prepare(`INSERT INTO transactions (transaction_id, account_id, amount, date, name, category) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
125
|
+
.run("t1", "a1", 50, "2025-01-15", "AMAZON MARKETPLACE", "GENERAL_MERCHANDISE");
|
|
126
|
+
db.prepare(`INSERT INTO recategorization_rules (match_field, match_pattern, target_category, label) VALUES (?, ?, ?, ?)`)
|
|
127
|
+
.run("name", "%AMAZON%", "GENERAL_MERCHANDISE_ONLINE", "Amazon → Online Shopping");
|
|
128
|
+
|
|
129
|
+
await runDailySync(db);
|
|
130
|
+
|
|
131
|
+
const txn = db.prepare(`SELECT category FROM transactions WHERE transaction_id = 't1'`).get() as any;
|
|
132
|
+
expect(txn.category).toBe("GENERAL_MERCHANDISE_ONLINE");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("skips rules with invalid match_field", async () => {
|
|
136
|
+
db.prepare(`INSERT INTO accounts (account_id, item_id, name, type, current_balance) VALUES (?, ?, ?, ?, ?)`)
|
|
137
|
+
.run("a1", "i1", "Checking", "depository", 1000);
|
|
138
|
+
db.prepare(`INSERT INTO transactions (transaction_id, account_id, amount, date, name, category) VALUES (?, ?, ?, ?, ?, ?)`)
|
|
139
|
+
.run("t1", "a1", 50, "2025-01-15", "Test", "OTHER");
|
|
140
|
+
// Inject an invalid field name
|
|
141
|
+
db.prepare(`INSERT INTO recategorization_rules (match_field, match_pattern, target_category, label) VALUES (?, ?, ?, ?)`)
|
|
142
|
+
.run("transaction_id; DROP TABLE transactions --", "%", "HACKED", "Bad rule");
|
|
143
|
+
|
|
144
|
+
await runDailySync(db);
|
|
145
|
+
|
|
146
|
+
// Transaction should be unchanged
|
|
147
|
+
const txn = db.prepare(`SELECT category FROM transactions WHERE transaction_id = 't1'`).get() as any;
|
|
148
|
+
expect(txn.category).toBe("OTHER");
|
|
149
|
+
});
|
|
150
|
+
});
|
package/src/daily-sync.ts
CHANGED
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
syncLiabilities,
|
|
8
8
|
} from "./plaid/sync.js";
|
|
9
9
|
import { calculateDailyScore, checkAchievements } from "./scoring/index.js";
|
|
10
|
+
import { decryptPlaidToken } from "./db/encryption.js";
|
|
11
|
+
import { config } from "./config.js";
|
|
10
12
|
|
|
11
13
|
/** Run the daily sync for a single database */
|
|
12
14
|
export async function runDailySync(db: Database) {
|
|
@@ -31,12 +33,25 @@ export async function runDailySync(db: Database) {
|
|
|
31
33
|
continue;
|
|
32
34
|
}
|
|
33
35
|
|
|
36
|
+
// Decrypt the stored access token
|
|
37
|
+
let accessToken: string;
|
|
38
|
+
try {
|
|
39
|
+
if (!config.plaidTokenSecret) {
|
|
40
|
+
console.error(` Skipping ${inst.name}: no plaidTokenSecret configured`);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
accessToken = decryptPlaidToken(inst.access_token, config.plaidTokenSecret);
|
|
44
|
+
} catch {
|
|
45
|
+
console.error(` Skipping ${inst.name}: failed to decrypt access token (wrong key or corrupt data)`);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
|
|
34
49
|
const products: string[] = JSON.parse(inst.products);
|
|
35
50
|
console.log(`Syncing: ${inst.name} (${products.join(", ")})`);
|
|
36
51
|
|
|
37
52
|
try {
|
|
38
53
|
// Always sync balances
|
|
39
|
-
const accountCount = await syncBalances(db,
|
|
54
|
+
const accountCount = await syncBalances(db, accessToken);
|
|
40
55
|
console.log(` Accounts: ${accountCount}`);
|
|
41
56
|
|
|
42
57
|
// Sync transactions if available
|
|
@@ -44,7 +59,7 @@ export async function runDailySync(db: Database) {
|
|
|
44
59
|
const txResult = await syncTransactions(
|
|
45
60
|
db,
|
|
46
61
|
inst.item_id,
|
|
47
|
-
|
|
62
|
+
accessToken,
|
|
48
63
|
inst.cursor
|
|
49
64
|
);
|
|
50
65
|
console.log(
|
|
@@ -54,7 +69,7 @@ export async function runDailySync(db: Database) {
|
|
|
54
69
|
|
|
55
70
|
// Sync investments if available
|
|
56
71
|
if (products.includes("investments")) {
|
|
57
|
-
const invResult = await syncInvestments(db,
|
|
72
|
+
const invResult = await syncInvestments(db, accessToken);
|
|
58
73
|
console.log(
|
|
59
74
|
` Investments: ${invResult.holdings} holdings, ${invResult.securities} securities`
|
|
60
75
|
);
|
|
@@ -62,7 +77,7 @@ export async function runDailySync(db: Database) {
|
|
|
62
77
|
|
|
63
78
|
// Sync liabilities if available
|
|
64
79
|
if (products.includes("liabilities")) {
|
|
65
|
-
await syncLiabilities(db,
|
|
80
|
+
await syncLiabilities(db, accessToken);
|
|
66
81
|
console.log(` Liabilities: synced`);
|
|
67
82
|
}
|
|
68
83
|
} catch (err: any) {
|
package/src/db/connection.ts
CHANGED
|
@@ -16,7 +16,18 @@ function openDb(dbPath: string, encryptionKey?: string): Database.Database {
|
|
|
16
16
|
const hexKey = Buffer.from(encryptionKey, "utf8").toString("hex");
|
|
17
17
|
db.pragma(`key="x'${hexKey}'"`);
|
|
18
18
|
}
|
|
19
|
-
|
|
19
|
+
|
|
20
|
+
// Verify the key works before proceeding
|
|
21
|
+
try {
|
|
22
|
+
db.pragma("journal_mode = WAL");
|
|
23
|
+
} catch (err: any) {
|
|
24
|
+
db.close();
|
|
25
|
+
throw new Error(
|
|
26
|
+
"Failed to open database. Wrong encryption key or corrupt database file. " +
|
|
27
|
+
"If you changed your encryption key, restore from backup or delete ~/.ray/data/finance.db to start fresh."
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
20
31
|
db.pragma("foreign_keys = ON");
|
|
21
32
|
migrate(db);
|
|
22
33
|
try { chmodSync(dbPath, 0o600); } catch {}
|