ray-finance 0.1.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 (70) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +195 -0
  3. package/dist/ai/agent.d.ts +2 -0
  4. package/dist/ai/agent.js +93 -0
  5. package/dist/ai/audit.d.ts +3 -0
  6. package/dist/ai/audit.js +6 -0
  7. package/dist/ai/context.d.ts +6 -0
  8. package/dist/ai/context.js +93 -0
  9. package/dist/ai/insights.d.ts +3 -0
  10. package/dist/ai/insights.js +401 -0
  11. package/dist/ai/memory.d.ts +14 -0
  12. package/dist/ai/memory.js +12 -0
  13. package/dist/ai/redactor.d.ts +2 -0
  14. package/dist/ai/redactor.js +103 -0
  15. package/dist/ai/system-prompt.d.ts +2 -0
  16. package/dist/ai/system-prompt.js +85 -0
  17. package/dist/ai/tools.d.ts +4 -0
  18. package/dist/ai/tools.js +699 -0
  19. package/dist/alerts/index.d.ts +11 -0
  20. package/dist/alerts/index.js +95 -0
  21. package/dist/auth/anthropic.d.ts +7 -0
  22. package/dist/auth/anthropic.js +85 -0
  23. package/dist/auth/pkce.d.ts +5 -0
  24. package/dist/auth/pkce.js +10 -0
  25. package/dist/auth/store.d.ts +12 -0
  26. package/dist/auth/store.js +51 -0
  27. package/dist/cli/backup.d.ts +2 -0
  28. package/dist/cli/backup.js +94 -0
  29. package/dist/cli/chat.d.ts +1 -0
  30. package/dist/cli/chat.js +203 -0
  31. package/dist/cli/commands.d.ts +13 -0
  32. package/dist/cli/commands.js +201 -0
  33. package/dist/cli/format.d.ts +14 -0
  34. package/dist/cli/format.js +144 -0
  35. package/dist/cli/index.d.ts +2 -0
  36. package/dist/cli/index.js +186 -0
  37. package/dist/cli/scheduler.d.ts +2 -0
  38. package/dist/cli/scheduler.js +114 -0
  39. package/dist/cli/setup.d.ts +1 -0
  40. package/dist/cli/setup.js +174 -0
  41. package/dist/config.d.ts +22 -0
  42. package/dist/config.js +60 -0
  43. package/dist/daily-sync.d.ts +7 -0
  44. package/dist/daily-sync.js +109 -0
  45. package/dist/db/connection.d.ts +5 -0
  46. package/dist/db/connection.js +45 -0
  47. package/dist/db/encryption.d.ts +3 -0
  48. package/dist/db/encryption.js +35 -0
  49. package/dist/db/helpers.d.ts +16 -0
  50. package/dist/db/helpers.js +45 -0
  51. package/dist/db/schema.d.ts +2 -0
  52. package/dist/db/schema.js +199 -0
  53. package/dist/index.d.ts +1 -0
  54. package/dist/index.js +1 -0
  55. package/dist/plaid/client.d.ts +2 -0
  56. package/dist/plaid/client.js +22 -0
  57. package/dist/plaid/link.d.ts +8 -0
  58. package/dist/plaid/link.js +23 -0
  59. package/dist/plaid/sync.d.ts +18 -0
  60. package/dist/plaid/sync.js +186 -0
  61. package/dist/public/favicon.png +0 -0
  62. package/dist/public/link.html +184 -0
  63. package/dist/public/ray-logo-dark.png +0 -0
  64. package/dist/queries/index.d.ts +163 -0
  65. package/dist/queries/index.js +411 -0
  66. package/dist/scoring/index.d.ts +53 -0
  67. package/dist/scoring/index.js +375 -0
  68. package/dist/server.d.ts +7 -0
  69. package/dist/server.js +172 -0
  70. package/package.json +60 -0
@@ -0,0 +1,174 @@
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
+ const parsed = new URL(url);
56
+ if (!parsed.hostname.endsWith("stripe.com") && !parsed.hostname.endsWith("rayfinance.app")) {
57
+ console.log(dim(` Unexpected checkout URL. Visit https://rayfinance.app to subscribe.\n`));
58
+ }
59
+ else {
60
+ await open(url);
61
+ }
62
+ }
63
+ catch {
64
+ console.log(dim(` Could not open checkout automatically.`));
65
+ console.log(dim(` Re-run ray setup to try again.\n`));
66
+ }
67
+ console.log(dim(" Complete checkout, then paste your key below.\n"));
68
+ }
69
+ const { rayApiKey } = await inquirer.prompt([{
70
+ type: "password",
71
+ name: "rayApiKey",
72
+ message: "Ray API key:",
73
+ validate: (v) => v.startsWith("ray_") || "Should start with ray_",
74
+ }]);
75
+ // Auto-generate encryption keys if not already set
76
+ const { generateKey } = await import("../db/encryption.js");
77
+ saveConfig({
78
+ userName,
79
+ rayApiKey,
80
+ anthropicKey: "",
81
+ model: "claude-sonnet-4-6",
82
+ plaidClientId: "",
83
+ plaidSecret: "",
84
+ plaidEnv: "production",
85
+ dbEncryptionKey: config.dbEncryptionKey || generateKey(),
86
+ plaidTokenSecret: config.plaidTokenSecret || generateKey(),
87
+ });
88
+ canLink = true;
89
+ }
90
+ else {
91
+ const answers = await inquirer.prompt([
92
+ {
93
+ type: "input",
94
+ name: "userName",
95
+ message: "Your name:",
96
+ default: config.userName !== "User" ? config.userName : undefined,
97
+ },
98
+ {
99
+ type: "password",
100
+ name: "anthropicKey",
101
+ message: "Anthropic API key:",
102
+ default: config.anthropicKey || undefined,
103
+ validate: (v) => v.length > 0 || "Required",
104
+ },
105
+ {
106
+ type: "list",
107
+ name: "model",
108
+ message: "AI model:",
109
+ choices: [
110
+ { name: "Claude Sonnet 4.6 (recommended)", value: "claude-sonnet-4-6" },
111
+ { name: "Claude Haiku 4.5 (faster, cheaper)", value: "claude-haiku-4-5" },
112
+ { name: "Claude Opus 4.6 (most capable)", value: "claude-opus-4-6" },
113
+ ],
114
+ default: config.model,
115
+ },
116
+ {
117
+ type: "password",
118
+ name: "plaidClientId",
119
+ message: "Plaid production client ID (enter to skip):",
120
+ default: config.plaidClientId || undefined,
121
+ },
122
+ {
123
+ type: "password",
124
+ name: "plaidSecret",
125
+ message: "Plaid production secret (enter to skip):",
126
+ default: config.plaidSecret || undefined,
127
+ },
128
+ {
129
+ type: "password",
130
+ name: "dbEncryptionKey",
131
+ message: "Database encryption key (enter to skip):",
132
+ default: config.dbEncryptionKey || undefined,
133
+ },
134
+ ]);
135
+ const { generateKey } = await import("../db/encryption.js");
136
+ saveConfig({
137
+ userName: answers.userName,
138
+ anthropicKey: answers.anthropicKey,
139
+ rayApiKey: "",
140
+ model: answers.model,
141
+ plaidClientId: answers.plaidClientId || "",
142
+ plaidSecret: answers.plaidSecret || "",
143
+ plaidEnv: "production",
144
+ dbEncryptionKey: answers.dbEncryptionKey || generateKey(),
145
+ plaidTokenSecret: config.plaidTokenSecret || generateKey(),
146
+ });
147
+ canLink = !!(answers.plaidClientId && answers.plaidSecret);
148
+ }
149
+ // Ensure data directory exists
150
+ const dbDir = dirname(config.dbPath);
151
+ if (!existsSync(dbDir))
152
+ mkdirSync(dbDir, { recursive: true });
153
+ // Initialize DB
154
+ const { getDb } = await import("../db/connection.js");
155
+ getDb();
156
+ // Create persistent context file
157
+ const { createContextTemplate } = await import("../ai/context.js");
158
+ createContextTemplate(config.userName);
159
+ console.log(`\n${chalk.green("✓")} Config saved`);
160
+ // Link first account immediately — this is the critical path
161
+ if (canLink) {
162
+ console.log();
163
+ const { runLink } = await import("./commands.js");
164
+ await runLink();
165
+ // Auto-schedule daily sync at 6am after first successful link
166
+ if (!config.syncSchedule) {
167
+ saveConfig({ syncSchedule: "06:00" });
168
+ const { installSyncSchedule } = await import("./scheduler.js");
169
+ installSyncSchedule("06:00");
170
+ console.log(`${chalk.green("✓")} Daily sync scheduled at 6:00 AM`);
171
+ }
172
+ }
173
+ console.log(`\n${chalk.green("✓")} Setup complete! Run ${chalk.bold("ray")} to start chatting.\n`);
174
+ }
@@ -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 "libsql";
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,109 @@
1
+ import { syncTransactions, syncBalances, syncInvestments, syncLiabilities, } from "./plaid/sync.js";
2
+ import { calculateDailyScore, checkAchievements } from "./scoring/index.js";
3
+ import { decryptPlaidToken } from "./db/encryption.js";
4
+ import { config } from "./config.js";
5
+ /** Run the daily sync for a single database */
6
+ export async function runDailySync(db) {
7
+ const institutions = db
8
+ .prepare(`SELECT item_id, access_token, name, products, cursor FROM institutions`)
9
+ .all();
10
+ if (institutions.length === 0) {
11
+ console.log("No linked institutions.");
12
+ return;
13
+ }
14
+ for (const inst of institutions) {
15
+ if (inst.access_token === "manual") {
16
+ console.log(`Skipping ${inst.name} (manual entry)`);
17
+ continue;
18
+ }
19
+ // Decrypt the stored access token
20
+ let accessToken;
21
+ try {
22
+ if (!config.plaidTokenSecret) {
23
+ console.error(` Skipping ${inst.name}: no plaidTokenSecret configured`);
24
+ continue;
25
+ }
26
+ accessToken = decryptPlaidToken(inst.access_token, config.plaidTokenSecret);
27
+ }
28
+ catch {
29
+ console.error(` Skipping ${inst.name}: failed to decrypt access token (wrong key or corrupt data)`);
30
+ continue;
31
+ }
32
+ const products = JSON.parse(inst.products);
33
+ console.log(`Syncing: ${inst.name} (${products.join(", ")})`);
34
+ try {
35
+ // Always sync balances
36
+ const accountCount = await syncBalances(db, accessToken);
37
+ console.log(` Accounts: ${accountCount}`);
38
+ // Sync transactions if available
39
+ if (products.includes("transactions")) {
40
+ const txResult = await syncTransactions(db, inst.item_id, accessToken, inst.cursor);
41
+ console.log(` Transactions: +${txResult.added} ~${txResult.modified} -${txResult.removed}`);
42
+ }
43
+ // Sync investments if available
44
+ if (products.includes("investments")) {
45
+ const invResult = await syncInvestments(db, accessToken);
46
+ console.log(` Investments: ${invResult.holdings} holdings, ${invResult.securities} securities`);
47
+ }
48
+ // Sync liabilities if available
49
+ if (products.includes("liabilities")) {
50
+ await syncLiabilities(db, accessToken);
51
+ console.log(` Liabilities: synced`);
52
+ }
53
+ }
54
+ catch (err) {
55
+ console.error(` Error syncing ${inst.name}: ${err.message}`);
56
+ }
57
+ }
58
+ // Snapshot net worth
59
+ const assets = db
60
+ .prepare(`SELECT COALESCE(SUM(current_balance), 0) as total FROM accounts WHERE type IN ('depository', 'investment', 'other')`)
61
+ .get();
62
+ const liabs = db
63
+ .prepare(`SELECT COALESCE(SUM(current_balance), 0) as total FROM accounts WHERE type IN ('credit', 'loan')`)
64
+ .get();
65
+ const netWorth = assets.total - liabs.total;
66
+ const today = new Date().toISOString().slice(0, 10);
67
+ db.prepare(`INSERT INTO net_worth_history (date, total_assets, total_liabilities, net_worth)
68
+ VALUES (?, ?, ?, ?)
69
+ 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);
70
+ console.log(`Net worth snapshot: $${netWorth.toLocaleString()} (assets: $${assets.total.toLocaleString()}, liabilities: $${liabs.total.toLocaleString()})`);
71
+ // Calculate daily score for yesterday
72
+ const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
73
+ const dailyScore = calculateDailyScore(db, yesterday);
74
+ console.log(` Daily score (${yesterday}): ${dailyScore.score}/100`);
75
+ const newAchievements = checkAchievements(db);
76
+ if (newAchievements.length > 0) {
77
+ for (const a of newAchievements) {
78
+ console.log(` Achievement unlocked: ${a.name} — ${a.description}`);
79
+ }
80
+ }
81
+ // Auto-recategorize using rules from recategorization_rules table
82
+ const rules = db.prepare(`SELECT match_field, match_pattern, target_category, target_subcategory, label FROM recategorization_rules`).all();
83
+ let totalRecat = 0;
84
+ for (const rule of rules) {
85
+ // Validate match_field to prevent SQL injection — only allow known column names
86
+ const allowedFields = ["name", "merchant_name", "category", "subcategory"];
87
+ if (!allowedFields.includes(rule.match_field)) {
88
+ console.error(` Skipping recat rule with invalid match_field: ${rule.match_field}`);
89
+ continue;
90
+ }
91
+ const result = rule.target_subcategory
92
+ ? 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)
93
+ : db.prepare(`UPDATE transactions SET category = ? WHERE ${rule.match_field} LIKE ? AND category != ?`).run(rule.target_category, rule.match_pattern, rule.target_category);
94
+ if (result.changes > 0) {
95
+ console.log(` Recategorized ${result.changes} txn(s): ${rule.label}`);
96
+ totalRecat += result.changes;
97
+ }
98
+ }
99
+ if (totalRecat > 0) {
100
+ console.log(`Auto-recategorized ${totalRecat} transaction(s).`);
101
+ }
102
+ console.log("Sync complete.");
103
+ }
104
+ /** Run daily sync (cron / CLI entry point) */
105
+ export async function runDailySyncAll() {
106
+ const { getDb } = await import("./db/connection.js");
107
+ const db = getDb();
108
+ await runDailySync(db);
109
+ }
@@ -0,0 +1,5 @@
1
+ import Database from "libsql";
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,45 @@
1
+ import Database from "libsql";
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 opts = {};
12
+ if (encryptionKey) {
13
+ opts.encryptionCipher = "aes256cbc";
14
+ opts.encryptionKey = encryptionKey;
15
+ }
16
+ const db = new Database(dbPath, opts);
17
+ // Verify the database is accessible
18
+ try {
19
+ db.pragma("journal_mode = WAL");
20
+ }
21
+ catch (err) {
22
+ db.close();
23
+ throw new Error("Failed to open database. Wrong encryption key or corrupt database file. " +
24
+ "If you changed your encryption key, restore from backup or delete ~/.ray/data/finance.db to start fresh.");
25
+ }
26
+ db.pragma("foreign_keys = ON");
27
+ migrate(db);
28
+ try {
29
+ chmodSync(dbPath, 0o600);
30
+ }
31
+ catch { }
32
+ return db;
33
+ }
34
+ /** Get the single DB instance */
35
+ export function getDb() {
36
+ if (!singleDb) {
37
+ singleDb = openDb(config.dbPath, config.dbEncryptionKey || undefined);
38
+ }
39
+ return singleDb;
40
+ }
41
+ /** Close all connections (for graceful shutdown) */
42
+ export function closeAll() {
43
+ singleDb?.close();
44
+ singleDb = null;
45
+ }
@@ -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,35 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
2
+ const SCRYPT_KEYLEN = 32;
3
+ const SALT_LEN = 16;
4
+ export function generateKey() {
5
+ return randomBytes(32).toString("hex");
6
+ }
7
+ function deriveKey(secret, salt) {
8
+ return scryptSync(secret, salt, SCRYPT_KEYLEN);
9
+ }
10
+ export function encryptPlaidToken(token, secret) {
11
+ const salt = randomBytes(SALT_LEN);
12
+ const key = deriveKey(secret, salt);
13
+ const iv = randomBytes(16);
14
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
15
+ const encrypted = Buffer.concat([cipher.update(token, "utf8"), cipher.final()]);
16
+ const authTag = cipher.getAuthTag();
17
+ return `${salt.toString("hex")}:${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted.toString("hex")}`;
18
+ }
19
+ export function decryptPlaidToken(encrypted, secret) {
20
+ const parts = encrypted.split(":");
21
+ // Support legacy 3-part format (static salt) and new 4-part format (random salt)
22
+ let salt, ivHex, authTagHex, dataHex;
23
+ if (parts.length === 3) {
24
+ salt = Buffer.from("ray-finance-plaid-token", "utf8");
25
+ [ivHex, authTagHex, dataHex] = parts;
26
+ }
27
+ else {
28
+ [, ivHex, authTagHex, dataHex] = parts;
29
+ salt = Buffer.from(parts[0], "hex");
30
+ }
31
+ const key = deriveKey(secret, salt);
32
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
33
+ decipher.setAuthTag(Buffer.from(authTagHex, "hex"));
34
+ return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8");
35
+ }
@@ -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 "libsql";
2
+ export declare function migrate(db: Database.Database): void;