ray-finance 0.3.5 → 0.3.7
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 +6 -2
- package/dist/ai/tools.js +3 -1
- package/dist/cli/backup.js +3 -3
- package/dist/cli/commands.js +52 -13
- package/dist/cli/doctor.js +1 -1
- package/dist/cli/index.js +17 -6
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/plaid/link.d.ts +2 -1
- package/dist/plaid/link.js +9 -1
- package/dist/plaid/sync.js +2 -2
- package/dist/server.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -42,7 +42,7 @@ Tell Ray about your family, goals, and financial strategy once. From then on, ev
|
|
|
42
42
|
|
|
43
43
|
### Set it and forget it
|
|
44
44
|
|
|
45
|
-
- **Bank sync via Plaid** — Connect checking, savings, credit cards, investments, and loans.
|
|
45
|
+
- **Bank sync via Plaid** — Connect checking, savings, credit cards, investments, and loans. Supports 18 countries: 🇺🇸 United States, 🇬🇧 United Kingdom, 🇨🇦 Canada, 🇫🇷 France, 🇩🇪 Germany, 🇪🇸 Spain, 🇮🇹 Italy, 🇳🇱 Netherlands, 🇮🇪 Ireland, 🇵🇱 Poland, 🇩🇰 Denmark, 🇳🇴 Norway, 🇸🇪 Sweden, 🇪🇪 Estonia, 🇱🇹 Lithuania, 🇱🇻 Latvia, 🇵🇹 Portugal, and 🇧🇪 Belgium.
|
|
46
46
|
- **Scheduled daily sync** — Automatic bank sync via launchd (macOS) or cron (Linux).
|
|
47
47
|
- **Auto-recategorization** — Define rules to automatically re-label transactions.
|
|
48
48
|
- **Export/import** — Back up and restore your financial data.
|
|
@@ -111,7 +111,7 @@ Run `ray --help` to see all available commands.
|
|
|
111
111
|
| `ray setup` | Configure API keys and preferences |
|
|
112
112
|
| `ray link` | Connect a new bank account |
|
|
113
113
|
| `ray add` | Add a manual account (home, car, crypto, etc.) |
|
|
114
|
-
| `ray remove` | Remove a manual account |
|
|
114
|
+
| `ray remove` | Remove a linked bank or manual account |
|
|
115
115
|
| `ray sync` | Pull latest transactions and balances |
|
|
116
116
|
| `ray status` | Quick financial dashboard |
|
|
117
117
|
| `ray accounts` | Linked accounts with balances |
|
|
@@ -197,6 +197,10 @@ RAY_API_KEY= # Ray API key (managed mode, replaces the above)
|
|
|
197
197
|
|
|
198
198
|
Have an idea? [Open a PR](https://github.com/cdinnison/ray-finance/pulls).
|
|
199
199
|
|
|
200
|
+
## Support
|
|
201
|
+
|
|
202
|
+
Questions, feedback, or need help getting set up? Email [clark@rayfinance.app](mailto:clark@rayfinance.app) or [open an issue](https://github.com/cdinnison/ray-finance/issues).
|
|
203
|
+
|
|
200
204
|
## Contributing
|
|
201
205
|
|
|
202
206
|
```bash
|
package/dist/ai/tools.js
CHANGED
|
@@ -378,7 +378,7 @@ export async function executeTool(db, toolName, toolInput) {
|
|
|
378
378
|
}
|
|
379
379
|
case "set_budget": {
|
|
380
380
|
db.prepare(`INSERT INTO budgets (category, monthly_limit) VALUES (?, ?)
|
|
381
|
-
ON CONFLICT(category) DO UPDATE SET monthly_limit = excluded.monthly_limit`).run(toolInput.category, toolInput.monthly_limit);
|
|
381
|
+
ON CONFLICT(category, period) DO UPDATE SET monthly_limit = excluded.monthly_limit`).run(toolInput.category, toolInput.monthly_limit);
|
|
382
382
|
return `Budget set: ${categoryLabel(toolInput.category)} at ${formatMoney(toolInput.monthly_limit)}/month`;
|
|
383
383
|
}
|
|
384
384
|
case "get_goals": {
|
|
@@ -411,6 +411,8 @@ export async function executeTool(db, toolName, toolInput) {
|
|
|
411
411
|
updates.push("target_date = ?");
|
|
412
412
|
params.push(toolInput.target_date);
|
|
413
413
|
}
|
|
414
|
+
if (updates.length === 0)
|
|
415
|
+
return `Goal "${toolInput.name}" exists but no changes provided.`;
|
|
414
416
|
params.push(existing.id);
|
|
415
417
|
db.prepare(`UPDATE goals SET ${updates.join(", ")} WHERE id = ?`).run(...params);
|
|
416
418
|
return `Goal "${toolInput.name}" updated.`;
|
package/dist/cli/backup.js
CHANGED
|
@@ -12,7 +12,7 @@ export function runExport(outputPath) {
|
|
|
12
12
|
exported_at: new Date().toISOString(),
|
|
13
13
|
context: readContext(),
|
|
14
14
|
memories: db.prepare("SELECT content, category FROM memories").all(),
|
|
15
|
-
goals: db.prepare("SELECT name, target_amount, current_amount,
|
|
15
|
+
goals: db.prepare("SELECT name, target_amount, current_amount, target_date, status FROM goals").all(),
|
|
16
16
|
budgets: db.prepare("SELECT category, monthly_limit, period FROM budgets").all(),
|
|
17
17
|
recat_rules: db.prepare("SELECT match_field, match_pattern, target_category, target_subcategory, label FROM recategorization_rules").all(),
|
|
18
18
|
settings: db.prepare("SELECT key, value FROM settings").all(),
|
|
@@ -54,10 +54,10 @@ export function runImport(inputPath) {
|
|
|
54
54
|
}
|
|
55
55
|
// Restore goals (skip if name already exists)
|
|
56
56
|
const existingGoal = db.prepare("SELECT 1 FROM goals WHERE name = ?");
|
|
57
|
-
const insertGoal = db.prepare("INSERT INTO goals (name, target_amount, current_amount,
|
|
57
|
+
const insertGoal = db.prepare("INSERT INTO goals (name, target_amount, current_amount, target_date, status) VALUES (?, ?, ?, ?, ?)");
|
|
58
58
|
for (const g of backup.goals) {
|
|
59
59
|
if (!existingGoal.get(g.name)) {
|
|
60
|
-
insertGoal.run(g.name, g.target_amount, g.current_amount, g.deadline, g.status);
|
|
60
|
+
insertGoal.run(g.name, g.target_amount, g.current_amount, g.target_date ?? g.deadline ?? null, g.status);
|
|
61
61
|
}
|
|
62
62
|
}
|
|
63
63
|
// Restore budgets
|
package/dist/cli/commands.js
CHANGED
|
@@ -190,7 +190,14 @@ export function showTransactions(options = {}) {
|
|
|
190
190
|
export async function showSpending(period = "this_month") {
|
|
191
191
|
const db = getDb();
|
|
192
192
|
const { resolvePeriod } = await import("../db/helpers.js");
|
|
193
|
-
|
|
193
|
+
let start, end;
|
|
194
|
+
try {
|
|
195
|
+
({ start, end } = resolvePeriod(period));
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
console.log(`\nUnknown period "${period}". Use: this_month, last_month, last_30, last_90, or START:END`);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
194
201
|
const rows = db.prepare(`SELECT category, SUM(amount) as total, COUNT(*) as count FROM transactions
|
|
195
202
|
WHERE amount > 0 AND date BETWEEN ? AND ? AND pending = 0
|
|
196
203
|
AND category NOT IN ('TRANSFER_OUT', 'TRANSFER_IN', 'LOAN_PAYMENTS')
|
|
@@ -352,26 +359,58 @@ export async function runAdd() {
|
|
|
352
359
|
export async function runRemove() {
|
|
353
360
|
const readline = await import("readline");
|
|
354
361
|
const db = getDb();
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
362
|
+
const entries = [];
|
|
363
|
+
// Linked institutions (exclude manual-assets)
|
|
364
|
+
const institutions = db.prepare(`SELECT item_id, name FROM institutions WHERE item_id != 'manual-assets' ORDER BY created_at`).all();
|
|
365
|
+
for (const inst of institutions) {
|
|
366
|
+
entries.push({ kind: "institution", item_id: inst.item_id, name: inst.name });
|
|
367
|
+
}
|
|
368
|
+
// Manual accounts
|
|
369
|
+
const manuals = getManualAccounts(db);
|
|
370
|
+
for (const a of manuals) {
|
|
371
|
+
entries.push({ kind: "manual", account_id: a.account_id, name: a.name, balance: a.current_balance, type: a.type, listing_url: a.listing_url });
|
|
372
|
+
}
|
|
373
|
+
if (entries.length === 0) {
|
|
374
|
+
console.log("\nNo accounts to remove. Use 'ray link' or 'ray add' to add one.");
|
|
358
375
|
return;
|
|
359
376
|
}
|
|
360
|
-
console.log(`\n${heading("
|
|
361
|
-
for (let i = 0; i <
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
377
|
+
console.log(`\n${heading("Accounts")}\n`);
|
|
378
|
+
for (let i = 0; i < entries.length; i++) {
|
|
379
|
+
const e = entries[i];
|
|
380
|
+
if (e.kind === "institution") {
|
|
381
|
+
const acctCount = db.prepare(`SELECT COUNT(*) as c FROM accounts WHERE item_id = ?`).get(e.item_id).c;
|
|
382
|
+
console.log(` ${dim(`${i + 1}.`)} ${e.name} ${dim(`(${acctCount} account${acctCount !== 1 ? "s" : ""}, linked)`)}`);
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const typeLabel = e.type === "loan" || e.type === "credit" ? "liability" : "asset";
|
|
386
|
+
const url = e.listing_url ? dim(` — ${e.listing_url}`) : "";
|
|
387
|
+
console.log(` ${dim(`${i + 1}.`)} ${e.name} ${rawFormatMoney(e.balance)} (${typeLabel})${url}`);
|
|
388
|
+
}
|
|
366
389
|
}
|
|
367
390
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
368
391
|
const answer = (await new Promise(resolve => rl.question(`\n Remove which? (number, or Enter to cancel): `, resolve))).trim();
|
|
369
392
|
rl.close();
|
|
370
393
|
const idx = parseInt(answer, 10) - 1;
|
|
371
|
-
if (isNaN(idx) || idx < 0 || idx >=
|
|
394
|
+
if (isNaN(idx) || idx < 0 || idx >= entries.length)
|
|
372
395
|
return;
|
|
373
|
-
|
|
374
|
-
|
|
396
|
+
const entry = entries[idx];
|
|
397
|
+
if (entry.kind === "manual") {
|
|
398
|
+
removeManualAccount(db, entry.account_id);
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
// Remove all data for this institution
|
|
402
|
+
const accounts = db.prepare(`SELECT account_id FROM accounts WHERE item_id = ?`).all(entry.item_id);
|
|
403
|
+
for (const acct of accounts) {
|
|
404
|
+
db.prepare(`DELETE FROM transactions WHERE account_id = ?`).run(acct.account_id);
|
|
405
|
+
db.prepare(`DELETE FROM holdings WHERE account_id = ?`).run(acct.account_id);
|
|
406
|
+
db.prepare(`DELETE FROM investment_transactions WHERE account_id = ?`).run(acct.account_id);
|
|
407
|
+
db.prepare(`DELETE FROM liabilities WHERE account_id = ?`).run(acct.account_id);
|
|
408
|
+
db.prepare(`DELETE FROM recurring WHERE account_id = ?`).run(acct.account_id);
|
|
409
|
+
}
|
|
410
|
+
db.prepare(`DELETE FROM accounts WHERE item_id = ?`).run(entry.item_id);
|
|
411
|
+
db.prepare(`DELETE FROM institutions WHERE item_id = ?`).run(entry.item_id);
|
|
412
|
+
}
|
|
413
|
+
console.log(chalk.green(`\n Removed ${entry.name}.`));
|
|
375
414
|
console.log();
|
|
376
415
|
}
|
|
377
416
|
export function showAlerts() {
|
package/dist/cli/doctor.js
CHANGED
|
@@ -103,7 +103,7 @@ export async function runDoctor() {
|
|
|
103
103
|
}
|
|
104
104
|
// ── Encryption ──
|
|
105
105
|
if (config.dbEncryptionKey) {
|
|
106
|
-
checks.push({ label: "Encryption", status: "ok", detail: "AES-256-
|
|
106
|
+
checks.push({ label: "Encryption", status: "ok", detail: "AES-256-GCM enabled" });
|
|
107
107
|
}
|
|
108
108
|
else {
|
|
109
109
|
checks.push({ label: "Encryption", status: "warn", detail: "No encryption key set. Data stored in plaintext." });
|
package/dist/cli/index.js
CHANGED
|
@@ -70,7 +70,7 @@ program
|
|
|
70
70
|
});
|
|
71
71
|
program
|
|
72
72
|
.command("remove")
|
|
73
|
-
.description("Remove a manual account")
|
|
73
|
+
.description("Remove a linked bank or manual account")
|
|
74
74
|
.action(async () => {
|
|
75
75
|
ensureConfigured();
|
|
76
76
|
const { runRemove } = await import("./commands.js");
|
|
@@ -192,25 +192,36 @@ program
|
|
|
192
192
|
const open = (await import("open")).default;
|
|
193
193
|
console.log("Opening billing portal...");
|
|
194
194
|
try {
|
|
195
|
-
const resp = await fetch(`${RAY_PROXY_BASE
|
|
195
|
+
const resp = await fetch(`${RAY_PROXY_BASE}/stripe/portal`, {
|
|
196
196
|
method: "POST",
|
|
197
197
|
headers: {
|
|
198
198
|
"content-type": "application/json",
|
|
199
199
|
"Authorization": `Bearer ${config.rayApiKey}`,
|
|
200
200
|
},
|
|
201
201
|
});
|
|
202
|
+
if (!resp.ok) {
|
|
203
|
+
const text = await resp.text().catch(() => "");
|
|
204
|
+
const msg = (() => { try {
|
|
205
|
+
return JSON.parse(text).error;
|
|
206
|
+
}
|
|
207
|
+
catch {
|
|
208
|
+
return text;
|
|
209
|
+
} })();
|
|
210
|
+
console.error(`Could not open billing portal (${resp.status}): ${msg || "unknown error"}`);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
202
213
|
const { url } = await resp.json();
|
|
203
214
|
// Only open URLs from trusted domains
|
|
204
215
|
const parsed = new URL(url);
|
|
205
216
|
if (!parsed.hostname.endsWith("stripe.com") && !parsed.hostname.endsWith("rayfinance.app")) {
|
|
206
|
-
console.error("Unexpected billing URL.
|
|
217
|
+
console.error("Unexpected billing URL.");
|
|
207
218
|
}
|
|
208
219
|
else {
|
|
209
220
|
await open(url);
|
|
210
221
|
}
|
|
211
222
|
}
|
|
212
|
-
catch {
|
|
213
|
-
console.error("Could not open billing portal
|
|
223
|
+
catch (err) {
|
|
224
|
+
console.error("Could not open billing portal:", err.message);
|
|
214
225
|
}
|
|
215
226
|
});
|
|
216
227
|
program
|
|
@@ -249,7 +260,7 @@ program.configureHelp({
|
|
|
249
260
|
{ name: "setup", desc: "Configure Ray (API keys, preferences)" },
|
|
250
261
|
{ name: "link", desc: "Link a new financial account via Plaid" },
|
|
251
262
|
{ name: "add", desc: "Add a manual account (home, car, crypto, etc.)" },
|
|
252
|
-
{ name: "remove", desc: "Remove a manual account" },
|
|
263
|
+
{ name: "remove", desc: "Remove a linked bank or manual account" },
|
|
253
264
|
{ name: "sync", desc: "Sync transactions from linked banks" },
|
|
254
265
|
{ name: "accounts", desc: "Show linked accounts and balances" },
|
|
255
266
|
{ name: "status", desc: "Show financial overview" },
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -37,6 +37,7 @@ function buildConfig() {
|
|
|
37
37
|
userName: file.userName || process.env.RAY_USER_NAME || "User",
|
|
38
38
|
thinkingBudget: file.thinkingBudget ?? (Number(process.env.RAY_THINKING_BUDGET) || 8000),
|
|
39
39
|
syncSchedule: file.syncSchedule || "",
|
|
40
|
+
plaidCountries: file.plaidCountries || ["US", "GB", "CA"],
|
|
40
41
|
};
|
|
41
42
|
}
|
|
42
43
|
export const config = buildConfig();
|
package/dist/plaid/link.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { Products } from "plaid";
|
|
1
|
+
import { CountryCode, Products } from "plaid";
|
|
2
|
+
export declare function getCountryCodes(): CountryCode[];
|
|
2
3
|
/** Create a link token for initializing Plaid Link */
|
|
3
4
|
export declare function createLinkToken(products?: Products[]): Promise<string>;
|
|
4
5
|
/** Exchange a public token from Plaid Link for an access token */
|
package/dist/plaid/link.js
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { plaidClient } from "./client.js";
|
|
2
2
|
import { CountryCode, Products } from "plaid";
|
|
3
|
+
import { config } from "../config.js";
|
|
4
|
+
export function getCountryCodes() {
|
|
5
|
+
const codes = config.plaidCountries
|
|
6
|
+
.map(c => c.toUpperCase())
|
|
7
|
+
.filter(c => c in CountryCode)
|
|
8
|
+
.map(c => CountryCode[c]);
|
|
9
|
+
return codes.length > 0 ? codes : [CountryCode.Us];
|
|
10
|
+
}
|
|
3
11
|
/** Create a link token for initializing Plaid Link */
|
|
4
12
|
export async function createLinkToken(products = [Products.Transactions]) {
|
|
5
13
|
const resp = await plaidClient.linkTokenCreate({
|
|
@@ -7,7 +15,7 @@ export async function createLinkToken(products = [Products.Transactions]) {
|
|
|
7
15
|
client_name: "Ray Finance",
|
|
8
16
|
products,
|
|
9
17
|
optional_products: [Products.Investments, Products.Liabilities],
|
|
10
|
-
country_codes:
|
|
18
|
+
country_codes: getCountryCodes(),
|
|
11
19
|
language: "en",
|
|
12
20
|
});
|
|
13
21
|
return resp.data.link_token;
|
package/dist/plaid/sync.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { plaidClient } from "./client.js";
|
|
2
|
-
import {
|
|
2
|
+
import { getCountryCodes } from "./link.js";
|
|
3
3
|
/** Check if a Plaid API error is "product not supported/enabled" — safe to ignore */
|
|
4
4
|
export function isProductNotSupported(err) {
|
|
5
5
|
const data = err?.response?.data;
|
|
@@ -26,7 +26,7 @@ export async function refreshProducts(db, itemId, accessToken) {
|
|
|
26
26
|
try {
|
|
27
27
|
const { data } = await plaidClient.institutionsGetById({
|
|
28
28
|
institution_id: resp.data.item.institution_id,
|
|
29
|
-
country_codes:
|
|
29
|
+
country_codes: getCountryCodes(),
|
|
30
30
|
options: { include_optional_metadata: true },
|
|
31
31
|
});
|
|
32
32
|
db.prepare(`UPDATE institutions SET logo = ?, primary_color = ? WHERE item_id = ?`)
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import { randomUUID } from "crypto";
|
|
|
5
5
|
import { createLinkToken, exchangeToken } from "./plaid/link.js";
|
|
6
6
|
import { syncBalances, syncTransactions, syncInvestments, syncInvestmentTransactions, syncLiabilities, syncRecurring, isProductNotSupported } from "./plaid/sync.js";
|
|
7
7
|
import { plaidClient } from "./plaid/client.js";
|
|
8
|
-
import {
|
|
8
|
+
import { getCountryCodes } from "./plaid/link.js";
|
|
9
9
|
import { encryptPlaidToken } from "./db/encryption.js";
|
|
10
10
|
import { config } from "./config.js";
|
|
11
11
|
import { getDb } from "./db/connection.js";
|
|
@@ -198,7 +198,7 @@ export function startLinkServer() {
|
|
|
198
198
|
try {
|
|
199
199
|
const { data } = await plaidClient.institutionsGetById({
|
|
200
200
|
institution_id: req.body.institution_id,
|
|
201
|
-
country_codes:
|
|
201
|
+
country_codes: getCountryCodes(),
|
|
202
202
|
options: { include_optional_metadata: true },
|
|
203
203
|
});
|
|
204
204
|
institutionLogo = data.institution.logo || null;
|