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 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.`;
@@ -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, deadline, status FROM goals").all(),
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, deadline, status) VALUES (?, ?, ?, ?, ?)");
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
@@ -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
- const { start, end } = resolvePeriod(period);
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 accounts = getManualAccounts(db);
356
- if (accounts.length === 0) {
357
- console.log("\nNo manual accounts. Use 'ray add' to create one.");
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("Manual Accounts")}\n`);
361
- for (let i = 0; i < accounts.length; i++) {
362
- const a = accounts[i];
363
- const typeLabel = a.type === "loan" || a.type === "credit" ? "liability" : "asset";
364
- const url = a.listing_url ? dim(` ${a.listing_url}`) : "";
365
- console.log(` ${dim(`${i + 1}.`)} ${a.name} ${rawFormatMoney(a.current_balance)} (${typeLabel})${url}`);
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 >= accounts.length)
394
+ if (isNaN(idx) || idx < 0 || idx >= entries.length)
372
395
  return;
373
- removeManualAccount(db, accounts[idx].account_id);
374
- console.log(chalk.green(`\n Removed ${accounts[idx].name}.`));
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() {
@@ -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-CBC enabled" });
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.replace("/v1", "")}/stripe/portal`, {
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. Visit https://rayfinance.app/billing");
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. Visit https://rayfinance.app/billing");
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
@@ -13,6 +13,7 @@ export interface RayConfig {
13
13
  userName: string;
14
14
  thinkingBudget: number;
15
15
  syncSchedule: string;
16
+ plaidCountries: string[];
16
17
  }
17
18
  export declare const RAY_PROXY_BASE = "https://api.rayfinance.app/v1";
18
19
  export declare function useManaged(): boolean;
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();
@@ -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 */
@@ -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: [CountryCode.Us],
18
+ country_codes: getCountryCodes(),
11
19
  language: "en",
12
20
  });
13
21
  return resp.data.link_token;
@@ -1,5 +1,5 @@
1
1
  import { plaidClient } from "./client.js";
2
- import { CountryCode } from "plaid";
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: [CountryCode.Us],
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 { CountryCode } from "plaid";
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: [CountryCode.Us],
201
+ country_codes: getCountryCodes(),
202
202
  options: { include_optional_metadata: true },
203
203
  });
204
204
  institutionLogo = data.institution.logo || null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ray-finance",
3
- "version": "0.3.5",
3
+ "version": "0.3.7",
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",