ray-finance 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/.claude/settings.local.json +16 -0
  2. package/.env.example +13 -0
  3. package/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
  4. package/.github/ISSUE_TEMPLATE/feature_request.md +9 -0
  5. package/.github/PULL_REQUEST_TEMPLATE.md +5 -0
  6. package/.github/workflows/ci.yml +21 -0
  7. package/CHANGELOG.md +16 -0
  8. package/CODE_OF_CONDUCT.md +31 -0
  9. package/CONTRIBUTING.md +41 -0
  10. package/Dockerfile +8 -0
  11. package/LICENSE +21 -0
  12. package/README.md +168 -0
  13. package/SECURITY.md +36 -0
  14. package/SPEC.md +374 -0
  15. package/dist/ai/agent.d.ts +2 -0
  16. package/dist/ai/agent.js +80 -0
  17. package/dist/ai/audit.d.ts +3 -0
  18. package/dist/ai/audit.js +6 -0
  19. package/dist/ai/context.d.ts +6 -0
  20. package/dist/ai/context.js +89 -0
  21. package/dist/ai/insights.d.ts +3 -0
  22. package/dist/ai/insights.js +378 -0
  23. package/dist/ai/memory.d.ts +14 -0
  24. package/dist/ai/memory.js +12 -0
  25. package/dist/ai/redactor.d.ts +2 -0
  26. package/dist/ai/redactor.js +92 -0
  27. package/dist/ai/system-prompt.d.ts +2 -0
  28. package/dist/ai/system-prompt.js +85 -0
  29. package/dist/ai/tools.d.ts +4 -0
  30. package/dist/ai/tools.js +695 -0
  31. package/dist/alerts/index.d.ts +11 -0
  32. package/dist/alerts/index.js +95 -0
  33. package/dist/auth/anthropic.d.ts +7 -0
  34. package/dist/auth/anthropic.js +85 -0
  35. package/dist/auth/pkce.d.ts +5 -0
  36. package/dist/auth/pkce.js +10 -0
  37. package/dist/auth/store.d.ts +12 -0
  38. package/dist/auth/store.js +51 -0
  39. package/dist/cli/backup.d.ts +2 -0
  40. package/dist/cli/backup.js +85 -0
  41. package/dist/cli/chat.d.ts +1 -0
  42. package/dist/cli/chat.js +97 -0
  43. package/dist/cli/commands.d.ts +13 -0
  44. package/dist/cli/commands.js +201 -0
  45. package/dist/cli/format.d.ts +12 -0
  46. package/dist/cli/format.js +119 -0
  47. package/dist/cli/index.d.ts +2 -0
  48. package/dist/cli/index.js +176 -0
  49. package/dist/cli/scheduler.d.ts +2 -0
  50. package/dist/cli/scheduler.js +114 -0
  51. package/dist/cli/setup.d.ts +1 -0
  52. package/dist/cli/setup.js +168 -0
  53. package/dist/config.d.ts +22 -0
  54. package/dist/config.js +60 -0
  55. package/dist/daily-sync.d.ts +7 -0
  56. package/dist/daily-sync.js +94 -0
  57. package/dist/db/connection.d.ts +5 -0
  58. package/dist/db/connection.js +37 -0
  59. package/dist/db/encryption.d.ts +3 -0
  60. package/dist/db/encryption.js +24 -0
  61. package/dist/db/helpers.d.ts +16 -0
  62. package/dist/db/helpers.js +45 -0
  63. package/dist/db/schema.d.ts +2 -0
  64. package/dist/db/schema.js +194 -0
  65. package/dist/index.d.ts +1 -0
  66. package/dist/index.js +1 -0
  67. package/dist/plaid/client.d.ts +2 -0
  68. package/dist/plaid/client.js +22 -0
  69. package/dist/plaid/link.d.ts +8 -0
  70. package/dist/plaid/link.js +23 -0
  71. package/dist/plaid/sync.d.ts +18 -0
  72. package/dist/plaid/sync.js +186 -0
  73. package/dist/public/link.html +161 -0
  74. package/dist/queries/index.d.ts +163 -0
  75. package/dist/queries/index.js +411 -0
  76. package/dist/scoring/index.d.ts +53 -0
  77. package/dist/scoring/index.js +375 -0
  78. package/dist/server.d.ts +7 -0
  79. package/dist/server.js +140 -0
  80. package/docker-compose.yml +9 -0
  81. package/package.json +55 -0
  82. package/site/next-env.d.ts +6 -0
  83. package/site/next.config.ts +7 -0
  84. package/site/package-lock.json +1661 -0
  85. package/site/package.json +24 -0
  86. package/site/postcss.config.mjs +7 -0
  87. package/site/public/favicon.png +0 -0
  88. package/site/public/ray-og.jpg +0 -0
  89. package/site/public/robots.txt +4 -0
  90. package/site/public/sitemap.xml +8 -0
  91. package/site/src/app/copy-command.tsx +30 -0
  92. package/site/src/app/globals.css +87 -0
  93. package/site/src/app/layout.tsx +64 -0
  94. package/site/src/app/page.tsx +841 -0
  95. package/site/src/app/pii-scramble.tsx +190 -0
  96. package/site/src/app/reveal.tsx +29 -0
  97. package/site/tsconfig.json +21 -0
  98. package/src/ai/agent.ts +106 -0
  99. package/src/ai/audit.ts +11 -0
  100. package/src/ai/context.ts +93 -0
  101. package/src/ai/insights.ts +474 -0
  102. package/src/ai/memory.ts +21 -0
  103. package/src/ai/redactor.ts +102 -0
  104. package/src/ai/system-prompt.ts +90 -0
  105. package/src/ai/tools.ts +716 -0
  106. package/src/alerts/index.ts +123 -0
  107. package/src/cli/backup.ts +113 -0
  108. package/src/cli/chat.ts +105 -0
  109. package/src/cli/commands.ts +240 -0
  110. package/src/cli/format.ts +149 -0
  111. package/src/cli/index.ts +193 -0
  112. package/src/cli/scheduler.ts +116 -0
  113. package/src/cli/setup.ts +189 -0
  114. package/src/config.ts +81 -0
  115. package/src/daily-sync.ts +155 -0
  116. package/src/db/connection.ts +38 -0
  117. package/src/db/encryption.ts +29 -0
  118. package/src/db/helpers.ts +47 -0
  119. package/src/db/schema.ts +196 -0
  120. package/src/index.ts +3 -0
  121. package/src/plaid/client.ts +25 -0
  122. package/src/plaid/link.ts +25 -0
  123. package/src/plaid/sync.ts +219 -0
  124. package/src/public/link.html +161 -0
  125. package/src/queries/index.ts +586 -0
  126. package/src/scoring/index.ts +468 -0
  127. package/src/server.ts +162 -0
  128. package/tsconfig.json +16 -0
@@ -0,0 +1,168 @@
1
+ import chalk from "chalk";
2
+ import { existsSync, mkdirSync } from "fs";
3
+ import { dirname } from "path";
4
+ import { config, saveConfig, isConfigured, RAY_PROXY_BASE } from "../config.js";
5
+ import { heading, dim } from "./format.js";
6
+ export async function runSetup() {
7
+ const inquirer = (await import("inquirer")).default;
8
+ console.log(`\n${heading("Ray Finance Setup")}\n`);
9
+ if (isConfigured()) {
10
+ const { proceed } = await inquirer.prompt([{
11
+ type: "confirm",
12
+ name: "proceed",
13
+ message: "Ray is already configured. Reconfigure?",
14
+ default: false,
15
+ }]);
16
+ if (!proceed)
17
+ return;
18
+ }
19
+ const { setupMode } = await inquirer.prompt([{
20
+ type: "list",
21
+ name: "setupMode",
22
+ message: "How would you like to set up Ray?",
23
+ choices: [
24
+ { name: "Quick setup — we handle the API keys, your data stays local", value: "managed" },
25
+ { name: "Self-hosted — bring your own Anthropic and Plaid credentials", value: "selfhosted" },
26
+ ],
27
+ }]);
28
+ let canLink = false;
29
+ if (setupMode === "managed") {
30
+ const { userName } = await inquirer.prompt([{
31
+ type: "input",
32
+ name: "userName",
33
+ message: "Your name:",
34
+ default: config.userName !== "User" ? config.userName : undefined,
35
+ }]);
36
+ const { hasKey } = await inquirer.prompt([{
37
+ type: "list",
38
+ name: "hasKey",
39
+ message: "Do you have a Ray API key?",
40
+ choices: [
41
+ { name: "Yes — I have a key", value: true },
42
+ { name: "No — I need to get one ($10/mo)", value: false },
43
+ ],
44
+ }]);
45
+ if (!hasKey) {
46
+ const open = (await import("open")).default;
47
+ const ora = (await import("ora")).default;
48
+ console.log(`\n Opening Stripe checkout in your browser...\n`);
49
+ try {
50
+ const resp = await fetch(`${RAY_PROXY_BASE.replace("/v1", "")}/stripe/checkout`, {
51
+ method: "POST",
52
+ headers: { "content-type": "application/json" },
53
+ });
54
+ const { url } = await resp.json();
55
+ await open(url);
56
+ }
57
+ catch {
58
+ console.log(dim(` Could not open checkout automatically.`));
59
+ console.log(dim(` Re-run ray setup to try again.\n`));
60
+ }
61
+ console.log(dim(" Complete checkout, then paste your key below.\n"));
62
+ }
63
+ const { rayApiKey } = await inquirer.prompt([{
64
+ type: "password",
65
+ name: "rayApiKey",
66
+ message: "Ray API key:",
67
+ validate: (v) => v.startsWith("ray_") || "Should start with ray_",
68
+ }]);
69
+ // Auto-generate encryption keys if not already set
70
+ const { generateKey } = await import("../db/encryption.js");
71
+ saveConfig({
72
+ userName,
73
+ rayApiKey,
74
+ anthropicKey: "",
75
+ model: "claude-sonnet-4-6",
76
+ plaidClientId: "",
77
+ plaidSecret: "",
78
+ plaidEnv: "production",
79
+ dbEncryptionKey: config.dbEncryptionKey || generateKey(),
80
+ plaidTokenSecret: config.plaidTokenSecret || generateKey(),
81
+ });
82
+ canLink = true;
83
+ }
84
+ else {
85
+ const answers = await inquirer.prompt([
86
+ {
87
+ type: "input",
88
+ name: "userName",
89
+ message: "Your name:",
90
+ default: config.userName !== "User" ? config.userName : undefined,
91
+ },
92
+ {
93
+ type: "password",
94
+ name: "anthropicKey",
95
+ message: "Anthropic API key:",
96
+ default: config.anthropicKey || undefined,
97
+ validate: (v) => v.length > 0 || "Required",
98
+ },
99
+ {
100
+ type: "list",
101
+ name: "model",
102
+ message: "AI model:",
103
+ choices: [
104
+ { name: "Claude Sonnet 4.6 (recommended)", value: "claude-sonnet-4-6" },
105
+ { name: "Claude Haiku 4.5 (faster, cheaper)", value: "claude-haiku-4-5" },
106
+ { name: "Claude Opus 4.6 (most capable)", value: "claude-opus-4-6" },
107
+ ],
108
+ default: config.model,
109
+ },
110
+ {
111
+ type: "password",
112
+ name: "plaidClientId",
113
+ message: "Plaid production client ID (enter to skip):",
114
+ default: config.plaidClientId || undefined,
115
+ },
116
+ {
117
+ type: "password",
118
+ name: "plaidSecret",
119
+ message: "Plaid production secret (enter to skip):",
120
+ default: config.plaidSecret || undefined,
121
+ },
122
+ {
123
+ type: "password",
124
+ name: "dbEncryptionKey",
125
+ message: "Database encryption key (enter to skip):",
126
+ default: config.dbEncryptionKey || undefined,
127
+ },
128
+ ]);
129
+ const { generateKey } = await import("../db/encryption.js");
130
+ saveConfig({
131
+ userName: answers.userName,
132
+ anthropicKey: answers.anthropicKey,
133
+ rayApiKey: "",
134
+ model: answers.model,
135
+ plaidClientId: answers.plaidClientId || "",
136
+ plaidSecret: answers.plaidSecret || "",
137
+ plaidEnv: "production",
138
+ dbEncryptionKey: answers.dbEncryptionKey || generateKey(),
139
+ plaidTokenSecret: config.plaidTokenSecret || generateKey(),
140
+ });
141
+ canLink = !!(answers.plaidClientId && answers.plaidSecret);
142
+ }
143
+ // Ensure data directory exists
144
+ const dbDir = dirname(config.dbPath);
145
+ if (!existsSync(dbDir))
146
+ mkdirSync(dbDir, { recursive: true });
147
+ // Initialize DB
148
+ const { getDb } = await import("../db/connection.js");
149
+ getDb();
150
+ // Create persistent context file
151
+ const { createContextTemplate } = await import("../ai/context.js");
152
+ createContextTemplate(config.userName);
153
+ console.log(`\n${chalk.green("✓")} Config saved`);
154
+ // Link first account immediately — this is the critical path
155
+ if (canLink) {
156
+ console.log();
157
+ const { runLink } = await import("./commands.js");
158
+ await runLink();
159
+ // Auto-schedule daily sync at 6am after first successful link
160
+ if (!config.syncSchedule) {
161
+ saveConfig({ syncSchedule: "06:00" });
162
+ const { installSyncSchedule } = await import("./scheduler.js");
163
+ installSyncSchedule("06:00");
164
+ console.log(`${chalk.green("✓")} Daily sync scheduled at 6:00 AM`);
165
+ }
166
+ }
167
+ console.log(`\n${chalk.green("✓")} Setup complete! Run ${chalk.bold("ray")} to start chatting.\n`);
168
+ }
@@ -0,0 +1,22 @@
1
+ import "dotenv/config";
2
+ export interface RayConfig {
3
+ anthropicKey: string;
4
+ rayApiKey: string;
5
+ model: string;
6
+ plaidClientId: string;
7
+ plaidSecret: string;
8
+ plaidEnv: string;
9
+ dbPath: string;
10
+ dbEncryptionKey: string;
11
+ plaidTokenSecret: string;
12
+ port: number;
13
+ userName: string;
14
+ thinkingBudget: number;
15
+ syncSchedule: string;
16
+ }
17
+ export declare const RAY_PROXY_BASE = "https://api.rayfinance.app/v1";
18
+ export declare function useManaged(): boolean;
19
+ export declare function getConfigPath(): string;
20
+ export declare const config: RayConfig;
21
+ export declare function isConfigured(): boolean;
22
+ export declare function saveConfig(partial: Partial<RayConfig>): void;
package/dist/config.js ADDED
@@ -0,0 +1,60 @@
1
+ import "dotenv/config";
2
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { homedir } from "os";
5
+ export const RAY_PROXY_BASE = "https://api.rayfinance.app/v1";
6
+ export function useManaged() {
7
+ return !!config.rayApiKey;
8
+ }
9
+ const RAY_DIR = resolve(homedir(), ".ray");
10
+ export function getConfigPath() {
11
+ return resolve(RAY_DIR, "config.json");
12
+ }
13
+ function loadFileConfig() {
14
+ const configPath = getConfigPath();
15
+ if (!existsSync(configPath))
16
+ return {};
17
+ try {
18
+ return JSON.parse(readFileSync(configPath, "utf-8"));
19
+ }
20
+ catch {
21
+ return {};
22
+ }
23
+ }
24
+ function buildConfig() {
25
+ const file = loadFileConfig();
26
+ return {
27
+ anthropicKey: file.anthropicKey || process.env.ANTHROPIC_API_KEY || "",
28
+ rayApiKey: file.rayApiKey || process.env.RAY_API_KEY || "",
29
+ model: file.model || process.env.RAY_MODEL || "claude-sonnet-4-6",
30
+ plaidClientId: file.plaidClientId || process.env.PLAID_CLIENT_ID || "",
31
+ plaidSecret: file.plaidSecret || process.env.PLAID_SECRET || "",
32
+ plaidEnv: file.plaidEnv || process.env.PLAID_ENV || "production",
33
+ dbPath: file.dbPath || process.env.DB_PATH || resolve(RAY_DIR, "data", "finance.db"),
34
+ dbEncryptionKey: file.dbEncryptionKey || process.env.DB_ENCRYPTION_KEY || "",
35
+ plaidTokenSecret: file.plaidTokenSecret || process.env.PLAID_TOKEN_SECRET || "",
36
+ port: file.port || Number(process.env.RAY_PORT) || 9876,
37
+ userName: file.userName || process.env.RAY_USER_NAME || "User",
38
+ thinkingBudget: file.thinkingBudget ?? (Number(process.env.RAY_THINKING_BUDGET) || 8000),
39
+ syncSchedule: file.syncSchedule || "",
40
+ };
41
+ }
42
+ export const config = buildConfig();
43
+ export function isConfigured() {
44
+ return !!config.anthropicKey || !!config.rayApiKey;
45
+ }
46
+ export function saveConfig(partial) {
47
+ const configPath = getConfigPath();
48
+ const dir = resolve(RAY_DIR);
49
+ if (!existsSync(dir))
50
+ mkdirSync(dir, { recursive: true });
51
+ const existing = loadFileConfig();
52
+ const merged = { ...existing, ...partial };
53
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", { mode: 0o600 });
54
+ try {
55
+ chmodSync(configPath, 0o600);
56
+ }
57
+ catch { }
58
+ // Update live config
59
+ Object.assign(config, merged);
60
+ }
@@ -0,0 +1,7 @@
1
+ import type BetterSqlite3 from "better-sqlite3-multiple-ciphers";
2
+ type Database = BetterSqlite3.Database;
3
+ /** Run the daily sync for a single database */
4
+ export declare function runDailySync(db: Database): Promise<void>;
5
+ /** Run daily sync (cron / CLI entry point) */
6
+ export declare function runDailySyncAll(): Promise<void>;
7
+ export {};
@@ -0,0 +1,94 @@
1
+ import { syncTransactions, syncBalances, syncInvestments, syncLiabilities, } from "./plaid/sync.js";
2
+ import { calculateDailyScore, checkAchievements } from "./scoring/index.js";
3
+ /** Run the daily sync for a single database */
4
+ export async function runDailySync(db) {
5
+ const institutions = db
6
+ .prepare(`SELECT item_id, access_token, name, products, cursor FROM institutions`)
7
+ .all();
8
+ if (institutions.length === 0) {
9
+ console.log("No linked institutions.");
10
+ return;
11
+ }
12
+ for (const inst of institutions) {
13
+ if (inst.access_token === "manual") {
14
+ console.log(`Skipping ${inst.name} (manual entry)`);
15
+ continue;
16
+ }
17
+ const products = JSON.parse(inst.products);
18
+ console.log(`Syncing: ${inst.name} (${products.join(", ")})`);
19
+ try {
20
+ // Always sync balances
21
+ const accountCount = await syncBalances(db, inst.access_token);
22
+ console.log(` Accounts: ${accountCount}`);
23
+ // Sync transactions if available
24
+ if (products.includes("transactions")) {
25
+ const txResult = await syncTransactions(db, inst.item_id, inst.access_token, inst.cursor);
26
+ console.log(` Transactions: +${txResult.added} ~${txResult.modified} -${txResult.removed}`);
27
+ }
28
+ // Sync investments if available
29
+ if (products.includes("investments")) {
30
+ const invResult = await syncInvestments(db, inst.access_token);
31
+ console.log(` Investments: ${invResult.holdings} holdings, ${invResult.securities} securities`);
32
+ }
33
+ // Sync liabilities if available
34
+ if (products.includes("liabilities")) {
35
+ await syncLiabilities(db, inst.access_token);
36
+ console.log(` Liabilities: synced`);
37
+ }
38
+ }
39
+ catch (err) {
40
+ console.error(` Error syncing ${inst.name}: ${err.message}`);
41
+ }
42
+ }
43
+ // Snapshot net worth
44
+ const assets = db
45
+ .prepare(`SELECT COALESCE(SUM(current_balance), 0) as total FROM accounts WHERE type IN ('depository', 'investment', 'other')`)
46
+ .get();
47
+ const liabs = db
48
+ .prepare(`SELECT COALESCE(SUM(current_balance), 0) as total FROM accounts WHERE type IN ('credit', 'loan')`)
49
+ .get();
50
+ const netWorth = assets.total - liabs.total;
51
+ const today = new Date().toISOString().slice(0, 10);
52
+ db.prepare(`INSERT INTO net_worth_history (date, total_assets, total_liabilities, net_worth)
53
+ VALUES (?, ?, ?, ?)
54
+ ON CONFLICT(date) DO UPDATE SET total_assets=excluded.total_assets, total_liabilities=excluded.total_liabilities, net_worth=excluded.net_worth`).run(today, assets.total, liabs.total, netWorth);
55
+ console.log(`Net worth snapshot: $${netWorth.toLocaleString()} (assets: $${assets.total.toLocaleString()}, liabilities: $${liabs.total.toLocaleString()})`);
56
+ // Calculate daily score for yesterday
57
+ const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
58
+ const dailyScore = calculateDailyScore(db, yesterday);
59
+ console.log(` Daily score (${yesterday}): ${dailyScore.score}/100`);
60
+ const newAchievements = checkAchievements(db);
61
+ if (newAchievements.length > 0) {
62
+ for (const a of newAchievements) {
63
+ console.log(` Achievement unlocked: ${a.name} — ${a.description}`);
64
+ }
65
+ }
66
+ // Auto-recategorize using rules from recategorization_rules table
67
+ const rules = db.prepare(`SELECT match_field, match_pattern, target_category, target_subcategory, label FROM recategorization_rules`).all();
68
+ let totalRecat = 0;
69
+ for (const rule of rules) {
70
+ // Validate match_field to prevent SQL injection — only allow known column names
71
+ const allowedFields = ["name", "merchant_name", "category", "subcategory"];
72
+ if (!allowedFields.includes(rule.match_field)) {
73
+ console.error(` Skipping recat rule with invalid match_field: ${rule.match_field}`);
74
+ continue;
75
+ }
76
+ const result = rule.target_subcategory
77
+ ? 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)
78
+ : db.prepare(`UPDATE transactions SET category = ? WHERE ${rule.match_field} LIKE ? AND category != ?`).run(rule.target_category, rule.match_pattern, rule.target_category);
79
+ if (result.changes > 0) {
80
+ console.log(` Recategorized ${result.changes} txn(s): ${rule.label}`);
81
+ totalRecat += result.changes;
82
+ }
83
+ }
84
+ if (totalRecat > 0) {
85
+ console.log(`Auto-recategorized ${totalRecat} transaction(s).`);
86
+ }
87
+ console.log("Sync complete.");
88
+ }
89
+ /** Run daily sync (cron / CLI entry point) */
90
+ export async function runDailySyncAll() {
91
+ const { getDb } = await import("./db/connection.js");
92
+ const db = getDb();
93
+ await runDailySync(db);
94
+ }
@@ -0,0 +1,5 @@
1
+ import Database from "better-sqlite3-multiple-ciphers";
2
+ /** Get the single DB instance */
3
+ export declare function getDb(): Database.Database;
4
+ /** Close all connections (for graceful shutdown) */
5
+ export declare function closeAll(): void;
@@ -0,0 +1,37 @@
1
+ import Database from "better-sqlite3-multiple-ciphers";
2
+ import { config } from "../config.js";
3
+ import { migrate } from "./schema.js";
4
+ import { dirname } from "path";
5
+ import { mkdirSync, existsSync, chmodSync } from "fs";
6
+ let singleDb = null;
7
+ function openDb(dbPath, encryptionKey) {
8
+ const dir = dirname(dbPath);
9
+ if (!existsSync(dir))
10
+ mkdirSync(dir, { recursive: true });
11
+ const db = new Database(dbPath);
12
+ if (encryptionKey) {
13
+ // Use hex key format to avoid pragma injection from special characters
14
+ const hexKey = Buffer.from(encryptionKey, "utf8").toString("hex");
15
+ db.pragma(`key="x'${hexKey}'"`);
16
+ }
17
+ db.pragma("journal_mode = WAL");
18
+ db.pragma("foreign_keys = ON");
19
+ migrate(db);
20
+ try {
21
+ chmodSync(dbPath, 0o600);
22
+ }
23
+ catch { }
24
+ return db;
25
+ }
26
+ /** Get the single DB instance */
27
+ export function getDb() {
28
+ if (!singleDb) {
29
+ singleDb = openDb(config.dbPath, config.dbEncryptionKey || undefined);
30
+ }
31
+ return singleDb;
32
+ }
33
+ /** Close all connections (for graceful shutdown) */
34
+ export function closeAll() {
35
+ singleDb?.close();
36
+ singleDb = null;
37
+ }
@@ -0,0 +1,3 @@
1
+ export declare function generateKey(): string;
2
+ export declare function encryptPlaidToken(token: string, secret: string): string;
3
+ export declare function decryptPlaidToken(encrypted: string, secret: string): string;
@@ -0,0 +1,24 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
2
+ const SCRYPT_SALT = "ray-finance-plaid-token"; // Static salt is fine — secret is already high-entropy
3
+ const SCRYPT_KEYLEN = 32;
4
+ export function generateKey() {
5
+ return randomBytes(32).toString("hex");
6
+ }
7
+ function deriveKey(secret) {
8
+ return scryptSync(secret, SCRYPT_SALT, SCRYPT_KEYLEN);
9
+ }
10
+ export function encryptPlaidToken(token, secret) {
11
+ const key = deriveKey(secret);
12
+ const iv = randomBytes(16);
13
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
14
+ const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
15
+ const authTag = cipher.getAuthTag();
16
+ return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
17
+ }
18
+ export function decryptPlaidToken(encrypted, secret) {
19
+ const [ivHex, authTagHex, dataHex] = encrypted.split(":");
20
+ const key = deriveKey(secret);
21
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
22
+ decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
23
+ return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8");
24
+ }
@@ -0,0 +1,16 @@
1
+ export declare function resolvePeriod(period: string): {
2
+ start: string;
3
+ end: string;
4
+ };
5
+ export declare function shiftDays(date: Date, days: number): Date;
6
+ export declare function simulatePayoff(balance: number, annualRate: number, monthlyPayment: number): {
7
+ months: number;
8
+ totalInterest: number;
9
+ schedule: {
10
+ month: number;
11
+ payment: number;
12
+ principal: number;
13
+ interest: number;
14
+ remaining: number;
15
+ }[];
16
+ };
@@ -0,0 +1,45 @@
1
+ export function resolvePeriod(period) {
2
+ const now = new Date();
3
+ const y = now.getFullYear();
4
+ const m = now.getMonth();
5
+ switch (period) {
6
+ case "this_month":
7
+ return { start: new Date(y, m, 1).toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) };
8
+ case "last_month":
9
+ return { start: new Date(y, m - 1, 1).toISOString().slice(0, 10), end: new Date(y, m, 0).toISOString().slice(0, 10) };
10
+ case "this_year":
11
+ return { start: new Date(y, 0, 1).toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) };
12
+ case "last_30":
13
+ return { start: shiftDays(now, -30).toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) };
14
+ case "last_90":
15
+ return { start: shiftDays(now, -90).toISOString().slice(0, 10), end: now.toISOString().slice(0, 10) };
16
+ default: {
17
+ const parts = period.split(":");
18
+ if (parts.length === 2)
19
+ return { start: parts[0], end: parts[1] };
20
+ throw new Error(`Unknown period: ${period}. Use this_month, last_month, this_year, last_30, last_90, or START:END`);
21
+ }
22
+ }
23
+ }
24
+ export function shiftDays(date, days) {
25
+ const d = new Date(date);
26
+ d.setDate(d.getDate() + days);
27
+ return d;
28
+ }
29
+ export function simulatePayoff(balance, annualRate, monthlyPayment) {
30
+ const monthlyRate = annualRate / 100 / 12;
31
+ let remaining = balance;
32
+ let totalInterest = 0;
33
+ const schedule = [];
34
+ let month = 0;
35
+ while (remaining > 0.01 && month < 600) {
36
+ month++;
37
+ const interest = remaining * monthlyRate;
38
+ const payment = Math.min(monthlyPayment, remaining + interest);
39
+ const principal = payment - interest;
40
+ remaining -= principal;
41
+ totalInterest += interest;
42
+ schedule.push({ month, payment: Math.round(payment * 100) / 100, principal: Math.round(principal * 100) / 100, interest: Math.round(interest * 100) / 100, remaining: Math.round(Math.max(0, remaining) * 100) / 100 });
43
+ }
44
+ return { months: month, totalInterest: Math.round(totalInterest * 100) / 100, schedule };
45
+ }
@@ -0,0 +1,2 @@
1
+ import type Database from "better-sqlite3-multiple-ciphers";
2
+ export declare function migrate(db: Database.Database): void;