ray-finance 0.5.0 → 0.5.1

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/README.md CHANGED
@@ -118,6 +118,12 @@ Bring your own AI and banking credentials. Free forever.
118
118
  4. Link your accounts — checking, savings, credit cards, investments, loans, mortgage
119
119
  5. Done
120
120
 
121
+ **Plaid OAuth banks (Chase, Capital One, etc.)** redirect through their bank's site, so you must:
122
+ - Set `PLAID_REDIRECT_URI=http://localhost:9876/oauth-return` (use your `RAY_PORT` if you changed it).
123
+ - In your [Plaid dashboard](https://dashboard.plaid.com) → Team Settings → API → Allowed redirect URIs, register the same URL. `http://localhost` is allowed by Plaid for local dev.
124
+
125
+ **Plaid optional products** — Investments and Liabilities are off by default. If your Plaid account is approved for them, opt in with `PLAID_OPTIONAL_PRODUCTS=investments,liabilities`. Leaving them on without approval will cause `ray link` to fail.
126
+
121
127
  ## Commands
122
128
 
123
129
  Run `ray --help` to see all available commands.
@@ -219,6 +225,8 @@ RAY_MODEL= # Model name (e.g. claude-sonnet-4-6, gpt-4o, llama3
219
225
  PLAID_CLIENT_ID= # Plaid client ID (US/Canada banks)
220
226
  PLAID_SECRET= # Plaid secret key
221
227
  PLAID_ENV=production # Plaid environment
228
+ PLAID_REDIRECT_URI= # Required for OAuth banks (Chase, Capital One). e.g. http://localhost:9876/oauth-return — must also be registered in your Plaid dashboard under Team Settings → API → Allowed redirect URIs
229
+ PLAID_OPTIONAL_PRODUCTS= # Comma-separated, e.g. investments,liabilities — only enable if your Plaid account is approved for them
222
230
  BRIDGE_CLIENT_ID= # Bridge client ID (European banks)
223
231
  BRIDGE_CLIENT_SECRET= # Bridge client secret
224
232
  BRIDGE_DEFAULT_EXTERNAL_USER_ID= # Optional: reuse an existing Bridge external_user_id
package/dist/config.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface RayConfig {
11
11
  plaidClientId: string;
12
12
  plaidSecret: string;
13
13
  plaidEnv: string;
14
+ plaidRedirectUri: string;
15
+ plaidOptionalProducts: string[];
14
16
  bridgeClientId: string;
15
17
  bridgeClientSecret: string;
16
18
  bridgeDefaultExternalUserId: string;
package/dist/config.js CHANGED
@@ -49,6 +49,12 @@ function buildConfig() {
49
49
  plaidClientId: file.plaidClientId || process.env.PLAID_CLIENT_ID || "",
50
50
  plaidSecret: file.plaidSecret || process.env.PLAID_SECRET || "",
51
51
  plaidEnv: file.plaidEnv || process.env.PLAID_ENV || "production",
52
+ plaidRedirectUri: file.plaidRedirectUri || process.env.PLAID_REDIRECT_URI || "",
53
+ plaidOptionalProducts: file.plaidOptionalProducts ||
54
+ (process.env.PLAID_OPTIONAL_PRODUCTS || "")
55
+ .split(",")
56
+ .map(s => s.trim())
57
+ .filter(Boolean),
52
58
  bridgeClientId: file.bridgeClientId || process.env.BRIDGE_CLIENT_ID || "",
53
59
  bridgeClientSecret: file.bridgeClientSecret || process.env.BRIDGE_CLIENT_SECRET || "",
54
60
  bridgeDefaultExternalUserId: file.bridgeDefaultExternalUserId || process.env.BRIDGE_DEFAULT_EXTERNAL_USER_ID || "",
@@ -8,13 +8,21 @@ export function getCountryCodes() {
8
8
  .map(c => CountryCode[c]);
9
9
  return codes.length > 0 ? codes : [CountryCode.Us];
10
10
  }
11
+ function resolveOptionalProducts() {
12
+ const valid = new Set(Object.values(Products));
13
+ return config.plaidOptionalProducts
14
+ .map(p => p.toLowerCase())
15
+ .filter(p => valid.has(p));
16
+ }
11
17
  /** Create a link token for initializing Plaid Link */
12
18
  export async function createLinkToken(products = [Products.Transactions]) {
19
+ const optional = resolveOptionalProducts();
13
20
  const resp = await plaidClient.linkTokenCreate({
14
21
  user: { client_user_id: "ray-user" },
15
22
  client_name: "Ray Finance",
16
23
  products,
17
- optional_products: [Products.Investments, Products.Liabilities],
24
+ ...(optional.length > 0 ? { optional_products: optional } : {}),
25
+ ...(config.plaidRedirectUri ? { redirect_uri: config.plaidRedirectUri } : {}),
18
26
  country_codes: getCountryCodes(),
19
27
  language: "en",
20
28
  });
@@ -60,7 +60,8 @@
60
60
  button:hover { opacity: 0.85; }
61
61
  button:disabled { opacity: 0.3; cursor: not-allowed; }
62
62
  .success { color: #34d399; font-weight: 500; }
63
- .error { color: #f87171; font-size: 13px; margin-top: 16px; }
63
+ .error { color: #f87171; font-size: 13px; margin-top: 16px; line-height: 1.5; }
64
+ .error a { color: #f87171; text-decoration: underline; }
64
65
  .success-title-check {
65
66
  width: 18px;
66
67
  height: 18px;
@@ -136,10 +137,17 @@
136
137
  });
137
138
  if (!res.ok) {
138
139
  const data = await res.json().catch(() => ({}));
139
- throw new Error(data.error || 'Failed to connect. Please run "ray link" again.');
140
+ const err = new Error(data.error || 'Failed to connect. Please run "ray link" again.');
141
+ err.code = data.error_code;
142
+ throw err;
140
143
  }
141
144
  const { link_token } = await res.json();
142
145
 
146
+ // Persist link_token + session for OAuth redirect flow (Chase, Capital One, etc.)
147
+ try {
148
+ localStorage.setItem('ray_plaid_link', JSON.stringify({ link_token, session_id: sessionId }));
149
+ } catch {}
150
+
143
151
  linkHandler = Plaid.create({
144
152
  token: link_token,
145
153
  onSuccess: async (publicToken, metadata) => {
@@ -156,7 +164,13 @@
156
164
  institution_id: metadata.institution?.institution_id || null,
157
165
  }),
158
166
  });
159
- if (!resp.ok) throw new Error('Exchange failed');
167
+ if (!resp.ok) {
168
+ const data = await resp.json().catch(() => ({}));
169
+ const err = new Error(data.error || 'Exchange failed');
170
+ err.code = data.error_code;
171
+ throw err;
172
+ }
173
+ try { localStorage.removeItem('ray_plaid_link'); } catch {}
160
174
  const result = await resp.json();
161
175
  document.getElementById('connect-view').style.display = 'none';
162
176
  document.getElementById('success-view').style.display = 'block';
@@ -171,7 +185,7 @@
171
185
  logoEl.style.display = 'inline-block';
172
186
  }
173
187
  } catch (e) {
174
- showError('Failed to link account. Please try again with "ray link".');
188
+ showError(e.message || 'Failed to link account. Please try again with "ray link".', e.code);
175
189
  }
176
190
  },
177
191
  onExit: (err) => {
@@ -182,13 +196,29 @@
182
196
  linkHandler.open();
183
197
  document.getElementById('connect-view').style.display = 'none';
184
198
  } catch (e) {
185
- showError(e.message || 'This link has expired. Please run "ray link" again.');
199
+ showError(e.message || 'This link has expired. Please run "ray link" again.', e.code);
200
+ }
201
+ }
202
+
203
+ function hintForCode(code) {
204
+ switch (code) {
205
+ case 'INVALID_PRODUCT':
206
+ case 'PRODUCTS_NOT_SUPPORTED':
207
+ case 'PRODUCT_NOT_READY':
208
+ return 'Your Plaid account isn\'t approved for this product. Request access at <a href="https://dashboard.plaid.com" target="_blank" rel="noopener">dashboard.plaid.com</a> → Account → Products.';
209
+ case 'INVALID_API_KEYS':
210
+ return 'Check your PLAID_CLIENT_ID and PLAID_SECRET in ~/.ray/config.json.';
211
+ case 'INVALID_FIELD':
212
+ return 'If your bank uses OAuth (Chase, Capital One), set PLAID_REDIRECT_URI and register the same URL in your Plaid dashboard.';
213
+ default:
214
+ return '';
186
215
  }
187
216
  }
188
217
 
189
- function showError(msg) {
218
+ function showError(msg, code) {
190
219
  const el = document.getElementById('error');
191
- el.textContent = msg;
220
+ const hint = hintForCode(code);
221
+ el.innerHTML = msg.replace(/</g, '&lt;') + (hint ? '<br><br>' + hint : '');
192
222
  el.style.display = 'block';
193
223
  }
194
224
 
@@ -0,0 +1,121 @@
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 — Returning from your bank</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: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas, monospace;
12
+ background: #202023;
13
+ color: #ffffff;
14
+ display: flex;
15
+ flex-direction: column;
16
+ align-items: center;
17
+ justify-content: center;
18
+ min-height: 100vh;
19
+ padding: 24px;
20
+ }
21
+ .top-logo {
22
+ position: absolute;
23
+ top: 32px;
24
+ left: 50%;
25
+ transform: translateX(-50%);
26
+ }
27
+ .top-logo img { height: 20px; filter: invert(1); }
28
+ .container { text-align: center; max-width: 400px; width: 100%; }
29
+ h1 { font-size: 16px; font-weight: 500; margin-bottom: 8px; color: #ffffff; letter-spacing: -0.01em; }
30
+ p { font-size: 13px; color: #9c9da1; margin-bottom: 24px; line-height: 1.6; }
31
+ .error { color: #f87171; font-size: 13px; margin-top: 16px; line-height: 1.5; }
32
+ .error a { color: #f87171; text-decoration: underline; }
33
+ .spinner {
34
+ display: inline-block;
35
+ width: 24px;
36
+ height: 24px;
37
+ border: 2px solid rgba(255, 255, 255, 0.1);
38
+ border-top-color: #9c9da1;
39
+ border-radius: 50%;
40
+ animation: spin 0.8s linear infinite;
41
+ margin-bottom: 20px;
42
+ }
43
+ @keyframes spin { to { transform: rotate(360deg); } }
44
+ </style>
45
+ </head>
46
+ <body>
47
+ <div class="top-logo"><img src="/ray-logo-dark.png" alt="Ray"></div>
48
+ <div class="container">
49
+ <div id="view">
50
+ <div class="spinner"></div>
51
+ <h1>Finishing connection</h1>
52
+ <p>Completing OAuth handoff with your bank...</p>
53
+ <div id="error" class="error" style="display:none"></div>
54
+ </div>
55
+ </div>
56
+
57
+ <script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
58
+ <script>
59
+ function showError(msg) {
60
+ const el = document.getElementById('error');
61
+ el.innerHTML = msg;
62
+ el.style.display = 'block';
63
+ }
64
+
65
+ async function resume() {
66
+ let stored;
67
+ try { stored = JSON.parse(localStorage.getItem('ray_plaid_link') || 'null'); } catch {}
68
+ if (!stored || !stored.link_token || !stored.session_id) {
69
+ showError('OAuth session not found. Please run "ray link" again.');
70
+ return;
71
+ }
72
+
73
+ if (typeof Plaid === 'undefined') {
74
+ let attempts = 0;
75
+ await new Promise((resolve, reject) => {
76
+ const check = setInterval(() => {
77
+ if (typeof Plaid !== 'undefined') { clearInterval(check); resolve(); }
78
+ else if (++attempts > 50) { clearInterval(check); reject(new Error('Failed to load Plaid SDK.')); }
79
+ }, 100);
80
+ });
81
+ }
82
+
83
+ const handler = Plaid.create({
84
+ token: stored.link_token,
85
+ receivedRedirectUri: window.location.href,
86
+ onSuccess: async (publicToken, metadata) => {
87
+ document.getElementById('view').innerHTML = '<div class="spinner"></div><h1>Linking</h1><p>Syncing ' + (metadata.institution?.name || 'account') + '...</p>';
88
+ try {
89
+ const resp = await fetch('/api/exchange', {
90
+ method: 'POST',
91
+ headers: { 'Content-Type': 'application/json' },
92
+ body: JSON.stringify({
93
+ public_token: publicToken,
94
+ session_id: stored.session_id,
95
+ institution_name: metadata.institution?.name || 'Bank Account',
96
+ institution_id: metadata.institution?.institution_id || null,
97
+ }),
98
+ });
99
+ if (!resp.ok) {
100
+ const data = await resp.json().catch(() => ({}));
101
+ throw new Error(data.error || 'Exchange failed');
102
+ }
103
+ try { localStorage.removeItem('ray_plaid_link'); } catch {}
104
+ const result = await resp.json();
105
+ const name = result.institution_name || metadata.institution?.name || 'Account';
106
+ document.getElementById('view').innerHTML = '<h1 style="color:#34d399">' + name + ' Connected</h1><p>Syncing transactions. You can close this page.</p>';
107
+ } catch (e) {
108
+ showError(e.message || 'Failed to link account. Please run "ray link" again.');
109
+ }
110
+ },
111
+ onExit: (err) => {
112
+ if (err) showError('Connection cancelled. Please run "ray link" again.');
113
+ },
114
+ });
115
+ handler.open();
116
+ }
117
+
118
+ resume().catch(e => showError(e.message || 'Unexpected error. Please run "ray link" again.'));
119
+ </script>
120
+ </body>
121
+ </html>
package/dist/server.js CHANGED
@@ -27,6 +27,20 @@ function isRateLimited(ip) {
27
27
  entry.count++;
28
28
  return entry.count > RATE_LIMIT_MAX;
29
29
  }
30
+ function plaidErrorJson(error, fallback) {
31
+ const data = error?.response?.data;
32
+ const code = data?.error_code;
33
+ const type = data?.error_type;
34
+ let message = data?.display_message ||
35
+ data?.error_message ||
36
+ error?.message ||
37
+ fallback;
38
+ if (code === "INVALID_API_KEYS") {
39
+ message =
40
+ "Plaid credentials error — make sure you're using production (not sandbox) keys. Check PLAID_CLIENT_ID and PLAID_SECRET in ~/.ray/config.json.";
41
+ }
42
+ return { error: message, error_code: code, error_type: type };
43
+ }
30
44
  export function startLinkServer() {
31
45
  const app = express();
32
46
  app.use(express.json());
@@ -62,6 +76,10 @@ export function startLinkServer() {
62
76
  }
63
77
  res.sendFile(resolve(__dirname, "public", "link.html"));
64
78
  });
79
+ // OAuth redirect return page (Chase, Capital One, etc. send the user here after bank login)
80
+ app.get("/oauth-return", (_req, res) => {
81
+ res.sendFile(resolve(__dirname, "public", "oauth-return.html"));
82
+ });
65
83
  // Create link token
66
84
  app.post("/api/link-token", async (req, res) => {
67
85
  try {
@@ -74,16 +92,9 @@ export function startLinkServer() {
74
92
  res.json({ link_token: linkToken });
75
93
  }
76
94
  catch (error) {
77
- console.error("Link token error:", error.message);
78
- const plaidStatus = error?.response?.status;
79
- if (plaidStatus === 400 || plaidStatus === 401 || plaidStatus === 403) {
80
- res.status(500).json({
81
- error: "Plaid credentials error — make sure you're using production (not sandbox) keys. Check PLAID_CLIENT_ID and PLAID_SECRET in ~/.ray/config.json.",
82
- });
83
- }
84
- else {
85
- res.status(500).json({ error: "Failed to create link token: " + (error.message || "unknown error") });
86
- }
95
+ const body = plaidErrorJson(error, "Failed to create link token");
96
+ console.error("Link token error:", body.error_code || "", error?.response?.data || error.message);
97
+ res.status(500).json(body);
87
98
  }
88
99
  });
89
100
  // Exchange public token
@@ -222,8 +233,9 @@ export function startLinkServer() {
222
233
  resolveComplete();
223
234
  }
224
235
  catch (error) {
225
- console.error("Token exchange error:", error.message);
226
- res.status(500).json({ error: "Failed to link account" });
236
+ const body = plaidErrorJson(error, "Failed to link account");
237
+ console.error("Token exchange error:", body.error_code || "", error?.response?.data || error.message);
238
+ res.status(500).json(body);
227
239
  }
228
240
  });
229
241
  const server = app.listen(config.port, "127.0.0.1");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ray-finance",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Local-first CLI that turns your bank data into a personal AI financial advisor",
5
5
  "type": "module",
6
6
  "license": "MIT",