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.
Files changed (62) hide show
  1. package/.claude/settings.local.json +3 -1
  2. package/.github/ray-logo.png +0 -0
  3. package/.github/workflows/ci.yml +1 -0
  4. package/Dockerfile +2 -2
  5. package/README.md +31 -10
  6. package/SECURITY.md +1 -1
  7. package/dist/ai/agent.js +16 -3
  8. package/dist/ai/context.js +6 -2
  9. package/dist/ai/insights.js +26 -3
  10. package/dist/ai/redactor.js +11 -0
  11. package/dist/ai/system-prompt.js +2 -2
  12. package/dist/ai/tools.js +4 -0
  13. package/dist/cli/backup.js +18 -9
  14. package/dist/cli/chat.js +146 -40
  15. package/dist/cli/format.d.ts +2 -0
  16. package/dist/cli/format.js +25 -0
  17. package/dist/cli/index.js +12 -2
  18. package/dist/cli/setup.js +7 -1
  19. package/dist/daily-sync.js +19 -4
  20. package/dist/db/connection.js +9 -1
  21. package/dist/db/encryption.js +18 -7
  22. package/dist/db/schema.js +6 -1
  23. package/dist/public/favicon.png +0 -0
  24. package/dist/public/link.html +47 -24
  25. package/dist/public/ray-logo-dark.png +0 -0
  26. package/dist/queries/index.js +8 -8
  27. package/dist/server.js +33 -1
  28. package/package.json +4 -2
  29. package/site/package-lock.json +43 -0
  30. package/site/package.json +1 -0
  31. package/site/public/ray-logo-dark.png +0 -0
  32. package/site/public/ray-logo-light.png +0 -0
  33. package/site/src/app/copy-command.tsx +1 -3
  34. package/site/src/app/layout.tsx +2 -1
  35. package/src/ai/agent.ts +15 -3
  36. package/src/ai/context.ts +3 -2
  37. package/src/ai/insights.ts +25 -3
  38. package/src/ai/redactor.test.ts +63 -0
  39. package/src/ai/redactor.ts +12 -0
  40. package/src/ai/system-prompt.ts +2 -2
  41. package/src/ai/tools.ts +4 -0
  42. package/src/cli/backup.ts +23 -10
  43. package/src/cli/chat.ts +155 -41
  44. package/src/cli/format.ts +31 -0
  45. package/src/cli/index.ts +12 -2
  46. package/src/cli/setup.ts +6 -1
  47. package/src/daily-sync.test.ts +150 -0
  48. package/src/daily-sync.ts +19 -4
  49. package/src/db/connection.ts +12 -1
  50. package/src/db/encryption.test.ts +86 -0
  51. package/src/db/encryption.ts +17 -7
  52. package/src/db/schema.test.ts +53 -0
  53. package/src/db/schema.ts +7 -1
  54. package/src/public/favicon.png +0 -0
  55. package/src/public/link.html +47 -24
  56. package/src/public/ray-logo-dark.png +0 -0
  57. package/src/queries/index.test.ts +397 -0
  58. package/src/queries/index.ts +8 -8
  59. package/src/server.ts +37 -1
  60. package/tsconfig.json +1 -1
  61. package/vitest.config.ts +7 -0
  62. package/SPEC.md +0 -374
@@ -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
 
@@ -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
- - Plain text only. No markdown syntax no asterisks, no hashtags, no backticks.
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 ALL CAPS or dashes for emphasis instead of bold/italic.
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("INSERT INTO memories (content, category) VALUES (?, ?)");
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 insertGoal = db.prepare("INSERT INTO goals (name, target_amount, current_amount, deadline, status) VALUES (?, ?, ?, ?, ?)");
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
- insertGoal.run(g.name, g.target_amount, g.current_amount, g.deadline, g.status);
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
- insertRule.run(r.match_field, r.match_pattern, r.target_category, r.target_subcategory, r.label);
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
- insertMilestone.run(m.name, m.target_date, m.monthly_savings, m.description);
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 briefing instead of generic header
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(`\nray`) + chalk.dim(` — ${config.userName}`));
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
- process.on("SIGINT", shutdown);
75
-
76
- try {
77
- while (true) {
78
- const cols = process.stdout.columns || 80;
79
- const rule = chalk.dim("─".repeat(cols));
80
- console.log(rule);
81
- const input = await rl.question(chalk.dim("❯ "));
82
- console.log(rule);
83
- const trimmed = input.trim();
84
-
85
- if (!trimmed) continue;
86
- if (trimmed === "/quit" || trimmed === "/exit" || trimmed === "/q") {
87
- shutdown();
88
- break;
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
- const spinner = ora({ text: "Thinking...", color: "cyan", discardStdin: false }).start();
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
- try {
94
- const response = await handleMessage(db, trimmed);
95
- spinner.stop();
96
- console.log(`\n${response}\n`);
97
- } catch (err: any) {
98
- spinner.stop();
99
- console.error(chalk.red("Error: " + err.message));
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("0.2.0")
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
- await open(url);
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
- await open(url);
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, inst.access_token);
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
- inst.access_token,
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, inst.access_token);
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, inst.access_token);
80
+ await syncLiabilities(db, accessToken);
66
81
  console.log(` Liabilities: synced`);
67
82
  }
68
83
  } catch (err: any) {
@@ -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
- db.pragma("journal_mode = WAL");
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 {}