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,199 @@
1
+ export function migrate(db) {
2
+ db.exec(`
3
+ CREATE TABLE IF NOT EXISTS institutions (
4
+ item_id TEXT PRIMARY KEY,
5
+ access_token TEXT NOT NULL,
6
+ name TEXT NOT NULL,
7
+ products TEXT NOT NULL DEFAULT '[]',
8
+ cursor TEXT,
9
+ created_at TEXT DEFAULT (datetime('now'))
10
+ );
11
+
12
+ CREATE TABLE IF NOT EXISTS accounts (
13
+ account_id TEXT PRIMARY KEY,
14
+ item_id TEXT NOT NULL REFERENCES institutions(item_id),
15
+ name TEXT NOT NULL,
16
+ official_name TEXT,
17
+ type TEXT NOT NULL,
18
+ subtype TEXT,
19
+ mask TEXT,
20
+ current_balance REAL,
21
+ available_balance REAL,
22
+ currency TEXT,
23
+ hidden INTEGER DEFAULT 0,
24
+ updated_at TEXT DEFAULT (datetime('now'))
25
+ );
26
+
27
+ CREATE TABLE IF NOT EXISTS transactions (
28
+ transaction_id TEXT PRIMARY KEY,
29
+ account_id TEXT NOT NULL REFERENCES accounts(account_id),
30
+ amount REAL NOT NULL,
31
+ date TEXT NOT NULL,
32
+ name TEXT NOT NULL,
33
+ merchant_name TEXT,
34
+ category TEXT,
35
+ subcategory TEXT,
36
+ pending INTEGER DEFAULT 0,
37
+ iso_currency_code TEXT,
38
+ payment_channel TEXT,
39
+ logo_url TEXT,
40
+ website TEXT,
41
+ label TEXT,
42
+ note TEXT,
43
+ created_at TEXT DEFAULT (datetime('now'))
44
+ );
45
+
46
+ CREATE TABLE IF NOT EXISTS holdings (
47
+ holding_id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ account_id TEXT NOT NULL REFERENCES accounts(account_id),
49
+ security_id TEXT,
50
+ quantity REAL NOT NULL,
51
+ cost_basis REAL,
52
+ value REAL,
53
+ price REAL,
54
+ price_as_of TEXT,
55
+ updated_at TEXT DEFAULT (datetime('now')),
56
+ UNIQUE(account_id, security_id)
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS securities (
60
+ security_id TEXT PRIMARY KEY,
61
+ name TEXT,
62
+ ticker TEXT,
63
+ type TEXT,
64
+ close_price REAL,
65
+ close_price_as_of TEXT
66
+ );
67
+
68
+ CREATE TABLE IF NOT EXISTS liabilities (
69
+ liability_id INTEGER PRIMARY KEY AUTOINCREMENT,
70
+ account_id TEXT NOT NULL REFERENCES accounts(account_id),
71
+ type TEXT NOT NULL,
72
+ interest_rate REAL,
73
+ origination_date TEXT,
74
+ original_balance REAL,
75
+ current_balance REAL,
76
+ minimum_payment REAL,
77
+ next_payment_due TEXT,
78
+ updated_at TEXT DEFAULT (datetime('now')),
79
+ UNIQUE(account_id, type)
80
+ );
81
+
82
+ CREATE TABLE IF NOT EXISTS net_worth_history (
83
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
84
+ date TEXT NOT NULL UNIQUE,
85
+ total_assets REAL NOT NULL,
86
+ total_liabilities REAL NOT NULL,
87
+ net_worth REAL NOT NULL
88
+ );
89
+
90
+ CREATE TABLE IF NOT EXISTS budgets (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ category TEXT NOT NULL,
93
+ monthly_limit REAL NOT NULL,
94
+ period TEXT DEFAULT 'monthly',
95
+ UNIQUE(category, period)
96
+ );
97
+
98
+ CREATE TABLE IF NOT EXISTS goals (
99
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
100
+ name TEXT NOT NULL,
101
+ target_amount REAL NOT NULL,
102
+ current_amount REAL DEFAULT 0,
103
+ target_date TEXT,
104
+ status TEXT DEFAULT 'active'
105
+ );
106
+
107
+ CREATE TABLE IF NOT EXISTS recurring (
108
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
109
+ merchant_name TEXT NOT NULL,
110
+ avg_amount REAL NOT NULL,
111
+ frequency TEXT NOT NULL,
112
+ category TEXT,
113
+ last_date TEXT,
114
+ account_id TEXT,
115
+ active INTEGER DEFAULT 1
116
+ );
117
+
118
+ CREATE TABLE IF NOT EXISTS daily_scores (
119
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
120
+ date TEXT NOT NULL UNIQUE,
121
+ score INTEGER NOT NULL,
122
+ restaurant_count INTEGER DEFAULT 0,
123
+ shopping_count INTEGER DEFAULT 0,
124
+ food_spend REAL DEFAULT 0,
125
+ total_spend REAL DEFAULT 0,
126
+ zero_spend INTEGER DEFAULT 0,
127
+ no_restaurant_streak INTEGER DEFAULT 0,
128
+ no_shopping_streak INTEGER DEFAULT 0,
129
+ on_pace_streak INTEGER DEFAULT 0
130
+ );
131
+
132
+ CREATE TABLE IF NOT EXISTS achievements (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ key TEXT NOT NULL UNIQUE,
135
+ name TEXT NOT NULL,
136
+ description TEXT,
137
+ unlocked_at TEXT DEFAULT (datetime('now'))
138
+ );
139
+
140
+ CREATE TABLE IF NOT EXISTS settings (
141
+ key TEXT PRIMARY KEY,
142
+ value TEXT NOT NULL
143
+ );
144
+
145
+ CREATE TABLE IF NOT EXISTS recategorization_rules (
146
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
147
+ match_field TEXT NOT NULL,
148
+ match_pattern TEXT NOT NULL,
149
+ target_category TEXT NOT NULL,
150
+ target_subcategory TEXT,
151
+ label TEXT
152
+ );
153
+
154
+ CREATE TABLE IF NOT EXISTS recurring_bills (
155
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
156
+ name TEXT NOT NULL,
157
+ amount REAL NOT NULL,
158
+ day_of_month INTEGER,
159
+ type TEXT,
160
+ account_id TEXT
161
+ );
162
+
163
+ CREATE TABLE IF NOT EXISTS milestones (
164
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
165
+ name TEXT NOT NULL,
166
+ target_date TEXT,
167
+ monthly_savings REAL,
168
+ description TEXT
169
+ );
170
+
171
+ CREATE TABLE IF NOT EXISTS conversation_history (
172
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
173
+ role TEXT NOT NULL,
174
+ content TEXT NOT NULL,
175
+ created_at TEXT DEFAULT (datetime('now'))
176
+ );
177
+
178
+ CREATE TABLE IF NOT EXISTS memories (
179
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
180
+ content TEXT NOT NULL,
181
+ category TEXT DEFAULT 'general',
182
+ created_at TEXT DEFAULT (datetime('now'))
183
+ );
184
+
185
+ CREATE TABLE IF NOT EXISTS ai_audit_log (
186
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
187
+ tool_name TEXT NOT NULL,
188
+ input_params TEXT,
189
+ result_summary TEXT,
190
+ tokens_used INTEGER,
191
+ created_at TEXT DEFAULT (datetime('now'))
192
+ );
193
+ `);
194
+ // Migrate: rename goals.deadline -> target_date for existing databases
195
+ const goalCols = db.prepare(`PRAGMA table_info(goals)`).all();
196
+ if (goalCols.some(c => c.name === "deadline") && !goalCols.some(c => c.name === "target_date")) {
197
+ db.exec(`ALTER TABLE goals RENAME COLUMN deadline TO target_date`);
198
+ }
199
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ import { PlaidApi } from "plaid";
2
+ export declare const plaidClient: PlaidApi;
@@ -0,0 +1,22 @@
1
+ import { Configuration, PlaidApi, PlaidEnvironments } from "plaid";
2
+ import { config, useManaged, RAY_PROXY_BASE } from "../config.js";
3
+ function buildPlaidConfig() {
4
+ if (useManaged()) {
5
+ return new Configuration({
6
+ basePath: `${RAY_PROXY_BASE}/plaid`,
7
+ baseOptions: {
8
+ headers: { Authorization: `Bearer ${config.rayApiKey}` },
9
+ },
10
+ });
11
+ }
12
+ return new Configuration({
13
+ basePath: PlaidEnvironments[config.plaidEnv],
14
+ baseOptions: {
15
+ headers: {
16
+ "PLAID-CLIENT-ID": config.plaidClientId,
17
+ "PLAID-SECRET": config.plaidSecret,
18
+ },
19
+ },
20
+ });
21
+ }
22
+ export const plaidClient = new PlaidApi(buildPlaidConfig());
@@ -0,0 +1,8 @@
1
+ import { Products } from "plaid";
2
+ /** Create a link token for initializing Plaid Link */
3
+ export declare function createLinkToken(products?: Products[]): Promise<string>;
4
+ /** Exchange a public token from Plaid Link for an access token */
5
+ export declare function exchangeToken(publicToken: string): Promise<{
6
+ accessToken: string;
7
+ itemId: string;
8
+ }>;
@@ -0,0 +1,23 @@
1
+ import { plaidClient } from "./client.js";
2
+ import { CountryCode, Products } from "plaid";
3
+ /** Create a link token for initializing Plaid Link */
4
+ export async function createLinkToken(products = [Products.Transactions]) {
5
+ const resp = await plaidClient.linkTokenCreate({
6
+ user: { client_user_id: "ray-user" },
7
+ client_name: "Ray Finance",
8
+ products,
9
+ country_codes: [CountryCode.Us],
10
+ language: "en",
11
+ });
12
+ return resp.data.link_token;
13
+ }
14
+ /** Exchange a public token from Plaid Link for an access token */
15
+ export async function exchangeToken(publicToken) {
16
+ const resp = await plaidClient.itemPublicTokenExchange({
17
+ public_token: publicToken,
18
+ });
19
+ return {
20
+ accessToken: resp.data.access_token,
21
+ itemId: resp.data.item_id,
22
+ };
23
+ }
@@ -0,0 +1,18 @@
1
+ import type BetterSqlite3 from "libsql";
2
+ type Database = BetterSqlite3.Database;
3
+ /** Sync transactions for an institution using Plaid's sync endpoint */
4
+ export declare function syncTransactions(db: Database, itemId: string, accessToken: string, cursor: string | null): Promise<{
5
+ added: number;
6
+ modified: number;
7
+ removed: number;
8
+ }>;
9
+ /** Sync account balances */
10
+ export declare function syncBalances(db: Database, accessToken: string): Promise<number>;
11
+ /** Sync investment holdings + securities */
12
+ export declare function syncInvestments(db: Database, accessToken: string): Promise<{
13
+ securities: number;
14
+ holdings: number;
15
+ }>;
16
+ /** Sync liabilities (credit, mortgage, student) */
17
+ export declare function syncLiabilities(db: Database, accessToken: string): Promise<string>;
18
+ export {};
@@ -0,0 +1,186 @@
1
+ import { plaidClient } from "./client.js";
2
+ /** Sync transactions for an institution using Plaid's sync endpoint */
3
+ export async function syncTransactions(db, itemId, accessToken, cursor) {
4
+ let hasMore = true;
5
+ let nextCursor = cursor || undefined;
6
+ let added = [];
7
+ let modified = [];
8
+ let removed = [];
9
+ while (hasMore) {
10
+ const resp = await plaidClient.transactionsSync({
11
+ access_token: accessToken,
12
+ cursor: nextCursor,
13
+ });
14
+ added = added.concat(resp.data.added);
15
+ modified = modified.concat(resp.data.modified);
16
+ removed = removed.concat(resp.data.removed);
17
+ hasMore = resp.data.has_more;
18
+ nextCursor = resp.data.next_cursor;
19
+ }
20
+ const upsertTx = db.prepare(`
21
+ INSERT INTO transactions (transaction_id, account_id, amount, date, name, merchant_name, category, subcategory, pending, iso_currency_code, payment_channel)
22
+ VALUES (@transaction_id, @account_id, @amount, @date, @name, @merchant_name, @category, @subcategory, @pending, @iso_currency_code, @payment_channel)
23
+ ON CONFLICT(transaction_id) DO UPDATE SET
24
+ amount=excluded.amount, date=excluded.date, name=excluded.name,
25
+ merchant_name=excluded.merchant_name, category=excluded.category,
26
+ subcategory=excluded.subcategory, pending=excluded.pending,
27
+ payment_channel=excluded.payment_channel
28
+ `);
29
+ const deleteTx = db.prepare(`DELETE FROM transactions WHERE transaction_id = ?`);
30
+ const insertMany = db.transaction(() => {
31
+ for (const t of [...added, ...modified]) {
32
+ const cats = t.personal_finance_category;
33
+ upsertTx.run({
34
+ transaction_id: t.transaction_id,
35
+ account_id: t.account_id,
36
+ amount: t.amount,
37
+ date: t.date,
38
+ name: t.name,
39
+ merchant_name: t.merchant_name || null,
40
+ category: cats?.primary || null,
41
+ subcategory: cats?.detailed || null,
42
+ pending: t.pending ? 1 : 0,
43
+ iso_currency_code: t.iso_currency_code || "USD",
44
+ payment_channel: t.payment_channel || null,
45
+ });
46
+ }
47
+ for (const r of removed) {
48
+ deleteTx.run(r.transaction_id);
49
+ }
50
+ });
51
+ insertMany();
52
+ // Update cursor
53
+ db.prepare(`UPDATE institutions SET cursor = ? WHERE item_id = ?`).run(nextCursor, itemId);
54
+ return { added: added.length, modified: modified.length, removed: removed.length };
55
+ }
56
+ /** Sync account balances */
57
+ export async function syncBalances(db, accessToken) {
58
+ const resp = await plaidClient.accountsGet({ access_token: accessToken });
59
+ const upsert = db.prepare(`
60
+ INSERT INTO accounts (account_id, item_id, name, official_name, type, subtype, mask, current_balance, available_balance, currency, updated_at)
61
+ VALUES (@account_id, @item_id, @name, @official_name, @type, @subtype, @mask, @current_balance, @available_balance, @currency, datetime('now'))
62
+ ON CONFLICT(account_id) DO UPDATE SET
63
+ name=excluded.name, official_name=excluded.official_name,
64
+ current_balance=excluded.current_balance, available_balance=excluded.available_balance,
65
+ updated_at=datetime('now')
66
+ `);
67
+ const itemId = resp.data.item.item_id;
68
+ const insertMany = db.transaction(() => {
69
+ for (const a of resp.data.accounts) {
70
+ upsert.run({
71
+ account_id: a.account_id,
72
+ item_id: itemId,
73
+ name: a.name,
74
+ official_name: a.official_name || null,
75
+ type: a.type,
76
+ subtype: a.subtype || null,
77
+ mask: a.mask || null,
78
+ current_balance: a.balances.current,
79
+ available_balance: a.balances.available,
80
+ currency: a.balances.iso_currency_code || "USD",
81
+ });
82
+ }
83
+ });
84
+ insertMany();
85
+ return resp.data.accounts.length;
86
+ }
87
+ /** Sync investment holdings + securities */
88
+ export async function syncInvestments(db, accessToken) {
89
+ const resp = await plaidClient.investmentsHoldingsGet({
90
+ access_token: accessToken,
91
+ });
92
+ const upsertSecurity = db.prepare(`
93
+ INSERT INTO securities (security_id, ticker, name, type, close_price, close_price_as_of)
94
+ VALUES (@security_id, @ticker, @name, @type, @close_price, @close_price_as_of)
95
+ ON CONFLICT(security_id) DO UPDATE SET
96
+ close_price=excluded.close_price, close_price_as_of=excluded.close_price_as_of
97
+ `);
98
+ const upsertHolding = db.prepare(`
99
+ INSERT INTO holdings (account_id, security_id, quantity, cost_basis, value, price, price_as_of, updated_at)
100
+ VALUES (@account_id, @security_id, @quantity, @cost_basis, @value, @price, @price_as_of, datetime('now'))
101
+ ON CONFLICT(account_id, security_id) DO UPDATE SET
102
+ quantity=excluded.quantity, cost_basis=excluded.cost_basis,
103
+ value=excluded.value, price=excluded.price,
104
+ price_as_of=excluded.price_as_of, updated_at=datetime('now')
105
+ `);
106
+ const insertMany = db.transaction(() => {
107
+ for (const s of resp.data.securities) {
108
+ upsertSecurity.run({
109
+ security_id: s.security_id,
110
+ ticker: s.ticker_symbol || null,
111
+ name: s.name || "Unknown",
112
+ type: s.type || null,
113
+ close_price: s.close_price || null,
114
+ close_price_as_of: s.close_price_as_of || null,
115
+ });
116
+ }
117
+ for (const h of resp.data.holdings) {
118
+ upsertHolding.run({
119
+ account_id: h.account_id,
120
+ security_id: h.security_id,
121
+ quantity: h.quantity,
122
+ cost_basis: h.cost_basis || null,
123
+ value: h.institution_value,
124
+ price: h.institution_price,
125
+ price_as_of: h.institution_price_as_of || null,
126
+ });
127
+ }
128
+ });
129
+ insertMany();
130
+ return { securities: resp.data.securities.length, holdings: resp.data.holdings.length };
131
+ }
132
+ /** Sync liabilities (credit, mortgage, student) */
133
+ export async function syncLiabilities(db, accessToken) {
134
+ const resp = await plaidClient.liabilitiesGet({ access_token: accessToken });
135
+ const upsert = db.prepare(`
136
+ INSERT INTO liabilities (account_id, type, interest_rate, origination_date, original_balance, current_balance, minimum_payment, next_payment_due, updated_at)
137
+ VALUES (@account_id, @type, @interest_rate, @origination_date, @original_balance, @current_balance, @minimum_payment, @next_payment_due, datetime('now'))
138
+ ON CONFLICT(account_id, type) DO UPDATE SET
139
+ interest_rate=excluded.interest_rate, current_balance=excluded.current_balance,
140
+ minimum_payment=excluded.minimum_payment, next_payment_due=excluded.next_payment_due,
141
+ updated_at=datetime('now')
142
+ `);
143
+ const insertMany = db.transaction(() => {
144
+ const credit = resp.data.liabilities.credit || [];
145
+ for (const c of credit) {
146
+ upsert.run({
147
+ account_id: c.account_id,
148
+ type: "credit",
149
+ interest_rate: c.aprs?.[0]?.apr_percentage || null,
150
+ origination_date: null,
151
+ original_balance: null,
152
+ current_balance: c.last_statement_balance,
153
+ minimum_payment: c.minimum_payment_amount,
154
+ next_payment_due: c.next_payment_due_date || null,
155
+ });
156
+ }
157
+ const mortgage = resp.data.liabilities.mortgage || [];
158
+ for (const m of mortgage) {
159
+ upsert.run({
160
+ account_id: m.account_id,
161
+ type: "mortgage",
162
+ interest_rate: m.interest_rate?.percentage || null,
163
+ origination_date: m.origination_date || null,
164
+ original_balance: m.origination_principal_amount || null,
165
+ current_balance: m.last_payment_amount || null,
166
+ minimum_payment: m.last_payment_amount || null,
167
+ next_payment_due: m.next_payment_due_date || null,
168
+ });
169
+ }
170
+ const student = resp.data.liabilities.student || [];
171
+ for (const s of student) {
172
+ upsert.run({
173
+ account_id: s.account_id,
174
+ type: "student",
175
+ interest_rate: s.interest_rate_percentage || null,
176
+ origination_date: s.origination_date || null,
177
+ original_balance: s.origination_principal_amount || null,
178
+ current_balance: s.last_payment_amount || null,
179
+ minimum_payment: s.minimum_payment_amount || null,
180
+ next_payment_due: s.next_payment_due_date || null,
181
+ });
182
+ }
183
+ });
184
+ insertMany();
185
+ return "ok";
186
+ }
Binary file
@@ -0,0 +1,184 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <link rel="icon" href="/favicon.png">
7
+ <title>Ray — Connect Account</title>
8
+ <style>
9
+ * { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
10
+ body {
11
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
12
+ background: #fafaf9;
13
+ color: #1c1917;
14
+ display: flex;
15
+ align-items: center;
16
+ justify-content: center;
17
+ min-height: 100vh;
18
+ padding: 24px;
19
+ }
20
+ .card {
21
+ background: #fff;
22
+ border: 1px solid rgba(214, 211, 209, 0.6);
23
+ border-radius: 16px;
24
+ padding: 48px 40px;
25
+ max-width: 400px;
26
+ width: 100%;
27
+ text-align: center;
28
+ }
29
+ .logo { margin-bottom: 32px; }
30
+ .logo img { height: 24px; }
31
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: #1c1917; }
32
+ p { font-size: 15px; color: #78716c; margin-bottom: 24px; line-height: 1.6; }
33
+ button {
34
+ background: #1c1917;
35
+ color: #fff;
36
+ border: none;
37
+ padding: 14px 32px;
38
+ border-radius: 9999px;
39
+ font-size: 15px;
40
+ font-weight: 500;
41
+ cursor: pointer;
42
+ width: 100%;
43
+ transition: background 0.2s;
44
+ }
45
+ button:hover { background: #292524; }
46
+ button:disabled { background: #d6d3d1; cursor: not-allowed; }
47
+ .success { color: #6ab318; font-weight: 600; }
48
+ .error { color: #dc3545; font-size: 14px; margin-top: 16px; }
49
+ .checkmark {
50
+ width: 48px;
51
+ height: 48px;
52
+ border: 2.5px solid #6ab318;
53
+ border-radius: 50%;
54
+ display: flex;
55
+ align-items: center;
56
+ justify-content: center;
57
+ margin: 0 auto 20px;
58
+ }
59
+ .checkmark svg {
60
+ width: 24px;
61
+ height: 24px;
62
+ stroke: #6ab318;
63
+ fill: none;
64
+ stroke-width: 2.5;
65
+ stroke-linecap: round;
66
+ stroke-linejoin: round;
67
+ }
68
+ .institution-logo {
69
+ width: 64px;
70
+ height: 64px;
71
+ border-radius: 16px;
72
+ object-fit: contain;
73
+ margin-bottom: 16px;
74
+ }
75
+ .spinner {
76
+ display: inline-block;
77
+ width: 20px;
78
+ height: 20px;
79
+ border: 2px solid #d6d3d1;
80
+ border-top-color: #78716c;
81
+ border-radius: 50%;
82
+ animation: spin 0.6s linear infinite;
83
+ margin-bottom: 16px;
84
+ }
85
+ @keyframes spin { to { transform: rotate(360deg); } }
86
+ </style>
87
+ </head>
88
+ <body>
89
+ <div class="card">
90
+ <div class="logo"><img src="/ray-logo-dark.png" alt="Ray"></div>
91
+ <div id="connect-view">
92
+ <div class="spinner"></div>
93
+ <p>Opening Plaid...</p>
94
+ <div id="error" class="error" style="display:none"></div>
95
+ </div>
96
+ <div id="success-view" style="display:none">
97
+ <div class="checkmark"><svg viewBox="0 0 24 24"><polyline points="4 12 10 18 20 6"/></svg></div>
98
+ <img id="institution-logo" class="institution-logo" style="display:none" alt="">
99
+ <h1 class="success" id="success-title">Account Connected</h1>
100
+ <p>Your account has been linked and your transactions are syncing. All data is stored locally on your machine and never leaves your device. You can close this page and return to your terminal.</p>
101
+ </div>
102
+ </div>
103
+
104
+ <script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
105
+ <script>
106
+ const sessionId = window.location.pathname.split('/link/')[1];
107
+ let linkHandler = null;
108
+
109
+ async function initPlaid() {
110
+ // Wait for Plaid SDK to load
111
+ if (typeof Plaid === 'undefined') {
112
+ let attempts = 0;
113
+ await new Promise((resolve, reject) => {
114
+ const check = setInterval(() => {
115
+ if (typeof Plaid !== 'undefined') { clearInterval(check); resolve(); }
116
+ else if (++attempts > 50) { clearInterval(check); reject(new Error('Failed to load Plaid SDK. Check your ad blocker or network connection.')); }
117
+ }, 100);
118
+ });
119
+ }
120
+ try {
121
+ const res = await fetch('/api/link-token', {
122
+ method: 'POST',
123
+ headers: { 'Content-Type': 'application/json' },
124
+ body: JSON.stringify({ session_id: sessionId }),
125
+ });
126
+ if (!res.ok) {
127
+ const data = await res.json().catch(() => ({}));
128
+ throw new Error(data.error || 'Failed to connect. Please run "ray link" again.');
129
+ }
130
+ const { link_token } = await res.json();
131
+
132
+ linkHandler = Plaid.create({
133
+ token: link_token,
134
+ onSuccess: async (publicToken, metadata) => {
135
+ document.getElementById('connect-view').innerHTML = '<p>Linking your account...</p>';
136
+ try {
137
+ const resp = await fetch('/api/exchange', {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({
141
+ public_token: publicToken,
142
+ session_id: sessionId,
143
+ institution_name: metadata.institution?.name || 'Bank Account',
144
+ institution_id: metadata.institution?.institution_id || null,
145
+ }),
146
+ });
147
+ if (!resp.ok) throw new Error('Exchange failed');
148
+ const result = await resp.json();
149
+ document.getElementById('connect-view').style.display = 'none';
150
+ document.getElementById('success-view').style.display = 'block';
151
+ const instName = result.institution_name || metadata.institution?.name;
152
+ if (instName) {
153
+ document.getElementById('success-title').textContent = instName + ' Connected';
154
+ }
155
+ if (result.institution_logo) {
156
+ const logoEl = document.getElementById('institution-logo');
157
+ logoEl.src = 'data:image/png;base64,' + result.institution_logo;
158
+ logoEl.style.display = 'inline-block';
159
+ }
160
+ } catch (e) {
161
+ showError('Failed to link account. Please try again with "ray link".');
162
+ }
163
+ },
164
+ onExit: (err) => {
165
+ if (err) showError('Connection cancelled. You can try again.');
166
+ },
167
+ });
168
+ // Auto-open Plaid Link
169
+ linkHandler.open();
170
+ } catch (e) {
171
+ showError(e.message || 'This link has expired. Please run "ray link" again.');
172
+ }
173
+ }
174
+
175
+ function showError(msg) {
176
+ const el = document.getElementById('error');
177
+ el.textContent = msg;
178
+ el.style.display = 'block';
179
+ }
180
+
181
+ initPlaid();
182
+ </script>
183
+ </body>
184
+ </html>
Binary file