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.
- package/.claude/settings.local.json +16 -0
- package/.env.example +13 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +19 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +9 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +5 -0
- package/.github/workflows/ci.yml +21 -0
- package/CHANGELOG.md +16 -0
- package/CODE_OF_CONDUCT.md +31 -0
- package/CONTRIBUTING.md +41 -0
- package/Dockerfile +8 -0
- package/LICENSE +21 -0
- package/README.md +168 -0
- package/SECURITY.md +36 -0
- package/SPEC.md +374 -0
- package/dist/ai/agent.d.ts +2 -0
- package/dist/ai/agent.js +80 -0
- package/dist/ai/audit.d.ts +3 -0
- package/dist/ai/audit.js +6 -0
- package/dist/ai/context.d.ts +6 -0
- package/dist/ai/context.js +89 -0
- package/dist/ai/insights.d.ts +3 -0
- package/dist/ai/insights.js +378 -0
- package/dist/ai/memory.d.ts +14 -0
- package/dist/ai/memory.js +12 -0
- package/dist/ai/redactor.d.ts +2 -0
- package/dist/ai/redactor.js +92 -0
- package/dist/ai/system-prompt.d.ts +2 -0
- package/dist/ai/system-prompt.js +85 -0
- package/dist/ai/tools.d.ts +4 -0
- package/dist/ai/tools.js +695 -0
- package/dist/alerts/index.d.ts +11 -0
- package/dist/alerts/index.js +95 -0
- package/dist/auth/anthropic.d.ts +7 -0
- package/dist/auth/anthropic.js +85 -0
- package/dist/auth/pkce.d.ts +5 -0
- package/dist/auth/pkce.js +10 -0
- package/dist/auth/store.d.ts +12 -0
- package/dist/auth/store.js +51 -0
- package/dist/cli/backup.d.ts +2 -0
- package/dist/cli/backup.js +85 -0
- package/dist/cli/chat.d.ts +1 -0
- package/dist/cli/chat.js +97 -0
- package/dist/cli/commands.d.ts +13 -0
- package/dist/cli/commands.js +201 -0
- package/dist/cli/format.d.ts +12 -0
- package/dist/cli/format.js +119 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +176 -0
- package/dist/cli/scheduler.d.ts +2 -0
- package/dist/cli/scheduler.js +114 -0
- package/dist/cli/setup.d.ts +1 -0
- package/dist/cli/setup.js +168 -0
- package/dist/config.d.ts +22 -0
- package/dist/config.js +60 -0
- package/dist/daily-sync.d.ts +7 -0
- package/dist/daily-sync.js +94 -0
- package/dist/db/connection.d.ts +5 -0
- package/dist/db/connection.js +37 -0
- package/dist/db/encryption.d.ts +3 -0
- package/dist/db/encryption.js +24 -0
- package/dist/db/helpers.d.ts +16 -0
- package/dist/db/helpers.js +45 -0
- package/dist/db/schema.d.ts +2 -0
- package/dist/db/schema.js +194 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/plaid/client.d.ts +2 -0
- package/dist/plaid/client.js +22 -0
- package/dist/plaid/link.d.ts +8 -0
- package/dist/plaid/link.js +23 -0
- package/dist/plaid/sync.d.ts +18 -0
- package/dist/plaid/sync.js +186 -0
- package/dist/public/link.html +161 -0
- package/dist/queries/index.d.ts +163 -0
- package/dist/queries/index.js +411 -0
- package/dist/scoring/index.d.ts +53 -0
- package/dist/scoring/index.js +375 -0
- package/dist/server.d.ts +7 -0
- package/dist/server.js +140 -0
- package/docker-compose.yml +9 -0
- package/package.json +55 -0
- package/site/next-env.d.ts +6 -0
- package/site/next.config.ts +7 -0
- package/site/package-lock.json +1661 -0
- package/site/package.json +24 -0
- package/site/postcss.config.mjs +7 -0
- package/site/public/favicon.png +0 -0
- package/site/public/ray-og.jpg +0 -0
- package/site/public/robots.txt +4 -0
- package/site/public/sitemap.xml +8 -0
- package/site/src/app/copy-command.tsx +30 -0
- package/site/src/app/globals.css +87 -0
- package/site/src/app/layout.tsx +64 -0
- package/site/src/app/page.tsx +841 -0
- package/site/src/app/pii-scramble.tsx +190 -0
- package/site/src/app/reveal.tsx +29 -0
- package/site/tsconfig.json +21 -0
- package/src/ai/agent.ts +106 -0
- package/src/ai/audit.ts +11 -0
- package/src/ai/context.ts +93 -0
- package/src/ai/insights.ts +474 -0
- package/src/ai/memory.ts +21 -0
- package/src/ai/redactor.ts +102 -0
- package/src/ai/system-prompt.ts +90 -0
- package/src/ai/tools.ts +716 -0
- package/src/alerts/index.ts +123 -0
- package/src/cli/backup.ts +113 -0
- package/src/cli/chat.ts +105 -0
- package/src/cli/commands.ts +240 -0
- package/src/cli/format.ts +149 -0
- package/src/cli/index.ts +193 -0
- package/src/cli/scheduler.ts +116 -0
- package/src/cli/setup.ts +189 -0
- package/src/config.ts +81 -0
- package/src/daily-sync.ts +155 -0
- package/src/db/connection.ts +38 -0
- package/src/db/encryption.ts +29 -0
- package/src/db/helpers.ts +47 -0
- package/src/db/schema.ts +196 -0
- package/src/index.ts +3 -0
- package/src/plaid/client.ts +25 -0
- package/src/plaid/link.ts +25 -0
- package/src/plaid/sync.ts +219 -0
- package/src/public/link.html +161 -0
- package/src/queries/index.ts +586 -0
- package/src/scoring/index.ts +468 -0
- package/src/server.ts +162 -0
- 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
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -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,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,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
|
+
}
|