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 +8 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +6 -0
- package/dist/plaid/link.js +9 -1
- package/dist/public/link.html +37 -7
- package/dist/public/oauth-return.html +121 -0
- package/dist/server.js +24 -12
- package/package.json +1 -1
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 || "",
|
package/dist/plaid/link.js
CHANGED
|
@@ -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:
|
|
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
|
});
|
package/dist/public/link.html
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
220
|
+
const hint = hintForCode(code);
|
|
221
|
+
el.innerHTML = msg.replace(/</g, '<') + (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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
226
|
-
|
|
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");
|