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,194 @@
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
+ deadline 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
+ }
@@ -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 "better-sqlite3-multiple-ciphers";
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
+ }
@@ -0,0 +1,161 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <link rel="preconnect" href="https://fonts.googleapis.com">
6
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
7
+ <link href="https://fonts.googleapis.com/css2?family=Geist+Mono:wght@700&display=swap" rel="stylesheet">
8
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
9
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><circle cx='50' cy='50' r='40' fill='%231a1a1a'/></svg>">
10
+ <title>Ray — Connect Account</title>
11
+ <style>
12
+ * { margin: 0; padding: 0; box-sizing: border-box; }
13
+ body {
14
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
15
+ background: #fafafa;
16
+ color: #1a1a1a;
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: center;
20
+ min-height: 100vh;
21
+ padding: 24px;
22
+ }
23
+ .card {
24
+ background: #fff;
25
+ border-radius: 12px;
26
+ padding: 40px 32px;
27
+ max-width: 400px;
28
+ width: 100%;
29
+ text-align: center;
30
+ box-shadow: 0 1px 3px rgba(0,0,0,0.08);
31
+ }
32
+ h1 { font-size: 22px; font-weight: 600; margin-bottom: 8px; }
33
+ p { font-size: 15px; color: #666; margin-bottom: 24px; line-height: 1.5; }
34
+ button {
35
+ background: #1a1a1a;
36
+ color: #fff;
37
+ border: none;
38
+ padding: 14px 32px;
39
+ border-radius: 9999px;
40
+ font-size: 16px;
41
+ font-weight: 500;
42
+ cursor: pointer;
43
+ width: 100%;
44
+ transition: background 0.2s;
45
+ }
46
+ button:hover { background: #333; }
47
+ button:disabled { background: #ccc; cursor: not-allowed; }
48
+ .success { color: #28a745; font-weight: 600; }
49
+ .error { color: #dc3545; font-size: 14px; margin-top: 16px; }
50
+ .logo { font-size: 28px; font-weight: 700; margin-bottom: 24px; color: #1a1a1a; font-family: 'Geist Mono', monospace; }
51
+ .checkmark {
52
+ width: 48px;
53
+ height: 48px;
54
+ border: 2.5px solid #28a745;
55
+ border-radius: 50%;
56
+ display: flex;
57
+ align-items: center;
58
+ justify-content: center;
59
+ margin: 0 auto 20px;
60
+ }
61
+ .checkmark svg {
62
+ width: 24px;
63
+ height: 24px;
64
+ stroke: #28a745;
65
+ fill: none;
66
+ stroke-width: 2.5;
67
+ stroke-linecap: round;
68
+ stroke-linejoin: round;
69
+ }
70
+ .institution-logo {
71
+ width: 64px;
72
+ height: 64px;
73
+ border-radius: 16px;
74
+ object-fit: contain;
75
+ margin-bottom: 16px;
76
+ }
77
+ </style>
78
+ </head>
79
+ <body>
80
+ <div class="card">
81
+ <div class="logo">RAY</div>
82
+ <div id="connect-view">
83
+ <p>Opening Plaid...</p>
84
+ <div id="error" class="error" style="display:none"></div>
85
+ </div>
86
+ <div id="success-view" style="display:none">
87
+ <div class="checkmark"><svg viewBox="0 0 24 24"><polyline points="4 12 10 18 20 6"/></svg></div>
88
+ <img id="institution-logo" class="institution-logo" style="display:none" alt="">
89
+ <h1 class="success" id="success-title">Account Connected</h1>
90
+ <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>
91
+ </div>
92
+ </div>
93
+
94
+ <script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
95
+ <script>
96
+ const sessionId = window.location.pathname.split('/link/')[1];
97
+ let linkHandler = null;
98
+
99
+ async function initPlaid() {
100
+ try {
101
+ const res = await fetch('/api/link-token', {
102
+ method: 'POST',
103
+ headers: { 'Content-Type': 'application/json' },
104
+ body: JSON.stringify({ session_id: sessionId }),
105
+ });
106
+ if (!res.ok) throw new Error('Session expired');
107
+ const { link_token } = await res.json();
108
+
109
+ linkHandler = Plaid.create({
110
+ token: link_token,
111
+ onSuccess: async (publicToken, metadata) => {
112
+ document.getElementById('connect-view').innerHTML = '<p>Linking your account...</p>';
113
+ try {
114
+ const resp = await fetch('/api/exchange', {
115
+ method: 'POST',
116
+ headers: { 'Content-Type': 'application/json' },
117
+ body: JSON.stringify({
118
+ public_token: publicToken,
119
+ session_id: sessionId,
120
+ institution_name: metadata.institution?.name || 'Bank Account',
121
+ institution_id: metadata.institution?.institution_id || null,
122
+ }),
123
+ });
124
+ if (!resp.ok) throw new Error('Exchange failed');
125
+ const result = await resp.json();
126
+ document.getElementById('connect-view').style.display = 'none';
127
+ document.getElementById('success-view').style.display = 'block';
128
+ const instName = result.institution_name || metadata.institution?.name;
129
+ if (instName) {
130
+ document.getElementById('success-title').textContent = instName + ' Connected';
131
+ }
132
+ if (result.institution_logo) {
133
+ const logoEl = document.getElementById('institution-logo');
134
+ logoEl.src = 'data:image/png;base64,' + result.institution_logo;
135
+ logoEl.style.display = 'inline-block';
136
+ }
137
+ } catch (e) {
138
+ showError('Failed to link account. Please try again with "ray link".');
139
+ }
140
+ },
141
+ onExit: (err) => {
142
+ if (err) showError('Connection cancelled. You can try again.');
143
+ },
144
+ });
145
+ // Auto-open Plaid Link
146
+ linkHandler.open();
147
+ } catch (e) {
148
+ showError('This link has expired. Please run "ray link" again.');
149
+ }
150
+ }
151
+
152
+ function showError(msg) {
153
+ const el = document.getElementById('error');
154
+ el.textContent = msg;
155
+ el.style.display = 'block';
156
+ }
157
+
158
+ initPlaid();
159
+ </script>
160
+ </body>
161
+ </html>