ray-finance 0.2.5 → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  export declare function runSync(): Promise<void>;
2
2
  export declare function runLink(): Promise<void>;
3
- export declare function showAccounts(): void;
3
+ export declare function showAccounts(): Promise<void>;
4
4
  export declare function showStatus(): void;
5
5
  export declare function showTransactions(options?: {
6
6
  limit?: number;
@@ -5,7 +5,7 @@ import { getLatestScore, getAchievements, getMonthlySavings } from "../scoring/i
5
5
  import { generateAlerts } from "../alerts/index.js";
6
6
  import { runDailySync } from "../daily-sync.js";
7
7
  import { startLinkServer } from "../server.js";
8
- import { heading, progressBar, formatMoney, formatMoneyColored, dim, formatDuration, formatError } from "./format.js";
8
+ import { heading, progressBar, formatMoney, formatMoneyColored, dim, formatDuration, formatError, renderLogo, institutionName } from "./format.js";
9
9
  export async function runSync() {
10
10
  const ora = (await import("ora")).default;
11
11
  const spinner = ora("Syncing transactions...").start();
@@ -36,9 +36,9 @@ export async function runLink() {
36
36
  stop();
37
37
  spinner.succeed("Bank account linked successfully!");
38
38
  }
39
- export function showAccounts() {
39
+ export async function showAccounts() {
40
40
  const db = getDb();
41
- const institutions = db.prepare(`SELECT i.name as institution, i.item_id, i.created_at,
41
+ const institutions = db.prepare(`SELECT i.name as institution, i.item_id, i.created_at, i.logo, i.primary_color,
42
42
  a.name, a.type, a.subtype, a.mask, a.current_balance, a.currency
43
43
  FROM institutions i
44
44
  LEFT JOIN accounts a ON a.item_id = i.item_id AND a.hidden = 0
@@ -48,20 +48,40 @@ export function showAccounts() {
48
48
  return;
49
49
  }
50
50
  console.log(`\n${heading("Linked Accounts")}\n`);
51
- let currentInst = "";
51
+ // Group rows by institution
52
+ const groups = new Map();
52
53
  for (const row of institutions) {
53
- if (row.institution !== currentInst) {
54
- currentInst = row.institution;
55
- console.log(chalk.bold(currentInst));
54
+ const key = row.item_id;
55
+ if (!groups.has(key))
56
+ groups.set(key, []);
57
+ groups.get(key).push(row);
58
+ }
59
+ // Compute column widths across all accounts for alignment
60
+ const allAccounts = institutions.filter(r => r.name);
61
+ const maxName = Math.max(...allAccounts.map(r => `${r.name}${r.mask ? ` ••${r.mask}` : ""}`.length), 0);
62
+ const maxLabel = Math.max(...allAccounts.map(r => (r.subtype || r.type || "").length), 0);
63
+ for (const [, rows] of groups) {
64
+ const first = rows[0];
65
+ // Logo inline with institution name
66
+ let logoStr = "";
67
+ if (first.logo) {
68
+ const logo = await renderLogo(first.logo);
69
+ if (logo)
70
+ logoStr = logo.replace(/\n/g, "") + " ";
56
71
  }
57
- if (!row.name) {
58
- console.log(dim(" No accounts found"));
59
- continue;
72
+ console.log(`${logoStr}${institutionName(first.institution, first.primary_color)}`);
73
+ for (const row of rows) {
74
+ if (!row.name) {
75
+ console.log(dim(" No accounts found"));
76
+ continue;
77
+ }
78
+ const nameWithMask = `${row.name}${row.mask ? ` ••${row.mask}` : ""}`;
79
+ const label = row.subtype || row.type || "";
80
+ const balance = row.current_balance != null ? rawFormatMoney(row.current_balance) : "—";
81
+ const namePad = nameWithMask.padEnd(maxName + 2);
82
+ const labelPad = label.padEnd(maxLabel + 2);
83
+ console.log(` ${namePad}${dim(labelPad)}${balance}`);
60
84
  }
61
- const mask = row.mask ? ` ••${row.mask}` : "";
62
- const balance = row.current_balance != null ? rawFormatMoney(row.current_balance) : "—";
63
- const label = row.subtype || row.type || "";
64
- console.log(` ${row.name}${dim(mask)} ${dim(label)} ${balance}`);
65
85
  }
66
86
  console.log("");
67
87
  }
@@ -27,3 +27,7 @@ export declare function helpScreen(commands: {
27
27
  /** Colorize AI response text for the terminal */
28
28
  export declare function formatResponse(text: string): string;
29
29
  export declare const DISCLAIMER: string;
30
+ /** Render a base64-encoded PNG as compact ANSI art (3 rows) */
31
+ export declare function renderLogo(base64: string): Promise<string>;
32
+ /** Color an institution name using its Plaid primary_color */
33
+ export declare function institutionName(name: string, primaryColor: string | null): string;
@@ -199,3 +199,26 @@ export function formatResponse(text) {
199
199
  }
200
200
  export const DISCLAIMER = "Ray is an AI tool, not a licensed financial advisor. Output is informational, " +
201
201
  "may be inaccurate, and does not constitute financial advice.";
202
+ // ─── Institution Logo Rendering ─── //
203
+ /** Render a base64-encoded PNG as compact ANSI art (3 rows) */
204
+ export async function renderLogo(base64) {
205
+ try {
206
+ const terminalImage = (await import("terminal-image")).default;
207
+ const buffer = Buffer.from(base64, "base64");
208
+ const rendered = await terminalImage.buffer(buffer, { height: 1, preserveAspectRatio: true });
209
+ return rendered.trimEnd();
210
+ }
211
+ catch {
212
+ return "";
213
+ }
214
+ }
215
+ /** Color an institution name using its Plaid primary_color */
216
+ export function institutionName(name, primaryColor) {
217
+ if (primaryColor) {
218
+ try {
219
+ return chalk.hex(primaryColor).bold(name);
220
+ }
221
+ catch { }
222
+ }
223
+ return chalk.bold(name);
224
+ }
package/dist/cli/index.js CHANGED
@@ -54,7 +54,7 @@ program
54
54
  .action(async () => {
55
55
  ensureConfigured();
56
56
  const { showAccounts } = await import("./commands.js");
57
- showAccounts();
57
+ await showAccounts();
58
58
  });
59
59
  program
60
60
  .command("status")
@@ -1,11 +1,12 @@
1
- import { syncTransactions, syncBalances, syncInvestments, syncLiabilities, syncRecurring, isProductNotSupported, } from "./plaid/sync.js";
1
+ import { syncTransactions, syncBalances, syncInvestments, syncInvestmentTransactions, syncLiabilities, syncRecurring, isProductNotSupported, refreshProducts, } from "./plaid/sync.js";
2
2
  import { calculateDailyScore, checkAchievements } from "./scoring/index.js";
3
3
  import { decryptPlaidToken } from "./db/encryption.js";
4
4
  import { config } from "./config.js";
5
+ import { institutionName } from "./cli/format.js";
5
6
  /** Run the daily sync for a single database */
6
7
  export async function runDailySync(db) {
7
8
  const institutions = db
8
- .prepare(`SELECT item_id, access_token, name, products, cursor FROM institutions`)
9
+ .prepare(`SELECT item_id, access_token, name, products, cursor, primary_color FROM institutions`)
9
10
  .all();
10
11
  if (institutions.length === 0) {
11
12
  console.log("No linked institutions.");
@@ -31,8 +32,15 @@ export async function runDailySync(db) {
31
32
  console.error(` Skipping ${inst.name}: failed to decrypt access token (wrong key or corrupt data)`);
32
33
  continue;
33
34
  }
34
- const products = JSON.parse(inst.products);
35
- console.log(`Syncing: ${inst.name} (${products.join(", ")})`);
35
+ let products = JSON.parse(inst.products);
36
+ // Refresh products list from Plaid if needed
37
+ try {
38
+ products = await refreshProducts(db, inst.item_id, accessToken);
39
+ }
40
+ catch {
41
+ // Non-fatal — use stored products
42
+ }
43
+ console.log(`Syncing: ${institutionName(inst.name, inst.primary_color)} (${products.join(", ")})`);
36
44
  try {
37
45
  instSynced++;
38
46
  // Always sync balances
@@ -45,22 +53,34 @@ export async function runDailySync(db) {
45
53
  console.log(` Transactions: +${txResult.added} ~${txResult.modified} -${txResult.removed}`);
46
54
  }
47
55
  // Sync investments
48
- try {
49
- const invResult = await syncInvestments(db, accessToken);
50
- console.log(` Investments: ${invResult.holdings} holdings, ${invResult.securities} securities`);
51
- }
52
- catch (e) {
53
- if (!isProductNotSupported(e))
54
- console.error(` Investments error: ${e.message}`);
56
+ if (products.includes("investments")) {
57
+ try {
58
+ const invResult = await syncInvestments(db, accessToken);
59
+ console.log(` Investments: ${invResult.holdings} holdings, ${invResult.securities} securities`);
60
+ }
61
+ catch (e) {
62
+ if (!isProductNotSupported(e))
63
+ console.error(` Investments error: ${e.message}`);
64
+ }
65
+ try {
66
+ const invTxResult = await syncInvestmentTransactions(db, accessToken);
67
+ console.log(` Investment transactions: ${invTxResult.transactions}`);
68
+ }
69
+ catch (e) {
70
+ if (!isProductNotSupported(e))
71
+ console.error(` Investment transactions error: ${e.message}`);
72
+ }
55
73
  }
56
74
  // Sync liabilities
57
- try {
58
- await syncLiabilities(db, accessToken);
59
- console.log(` Liabilities: synced`);
60
- }
61
- catch (e) {
62
- if (!isProductNotSupported(e))
63
- console.error(` Liabilities error: ${e.message}`);
75
+ if (products.includes("liabilities")) {
76
+ try {
77
+ await syncLiabilities(db, accessToken);
78
+ console.log(` Liabilities: synced`);
79
+ }
80
+ catch (e) {
81
+ if (!isProductNotSupported(e))
82
+ console.error(` Liabilities error: ${e.message}`);
83
+ }
64
84
  }
65
85
  // Sync recurring transaction streams
66
86
  if (products.includes("transactions")) {
package/dist/db/schema.js CHANGED
@@ -189,6 +189,22 @@ export function migrate(db) {
189
189
  created_at TEXT DEFAULT (datetime('now'))
190
190
  );
191
191
 
192
+ CREATE TABLE IF NOT EXISTS investment_transactions (
193
+ investment_transaction_id TEXT PRIMARY KEY,
194
+ account_id TEXT NOT NULL REFERENCES accounts(account_id),
195
+ security_id TEXT,
196
+ date TEXT NOT NULL,
197
+ name TEXT NOT NULL,
198
+ quantity REAL,
199
+ amount REAL NOT NULL,
200
+ price REAL,
201
+ fees REAL,
202
+ type TEXT,
203
+ subtype TEXT,
204
+ iso_currency_code TEXT,
205
+ created_at TEXT DEFAULT (datetime('now'))
206
+ );
207
+
192
208
  CREATE TABLE IF NOT EXISTS ai_audit_log (
193
209
  id INTEGER PRIMARY KEY AUTOINCREMENT,
194
210
  tool_name TEXT NOT NULL,
@@ -198,11 +214,48 @@ export function migrate(db) {
198
214
  created_at TEXT DEFAULT (datetime('now'))
199
215
  );
200
216
  `);
217
+ // Migrate: add logo and primary_color to institutions
218
+ const instCols = db.prepare(`PRAGMA table_info(institutions)`).all();
219
+ if (!instCols.some(c => c.name === "logo")) {
220
+ db.exec(`ALTER TABLE institutions ADD COLUMN logo TEXT`);
221
+ db.exec(`ALTER TABLE institutions ADD COLUMN primary_color TEXT`);
222
+ }
201
223
  // Migrate: rename goals.deadline -> target_date for existing databases
202
224
  const goalCols = db.prepare(`PRAGMA table_info(goals)`).all();
203
225
  if (goalCols.some(c => c.name === "deadline") && !goalCols.some(c => c.name === "target_date")) {
204
226
  db.exec(`ALTER TABLE goals RENAME COLUMN deadline TO target_date`);
205
227
  }
228
+ // Migrate: add balance_limit to accounts
229
+ const acctCols = db.prepare(`PRAGMA table_info(accounts)`).all();
230
+ if (!acctCols.some(c => c.name === "balance_limit")) {
231
+ db.exec(`ALTER TABLE accounts ADD COLUMN balance_limit REAL`);
232
+ }
233
+ // Migrate: add vesting columns to holdings
234
+ const holdCols = db.prepare(`PRAGMA table_info(holdings)`).all();
235
+ if (!holdCols.some(c => c.name === "vested_value")) {
236
+ db.exec(`ALTER TABLE holdings ADD COLUMN vested_value REAL`);
237
+ db.exec(`ALTER TABLE holdings ADD COLUMN vested_quantity REAL`);
238
+ }
239
+ // Migrate: expand liabilities with type-specific columns
240
+ const liabCols = db.prepare(`PRAGMA table_info(liabilities)`).all();
241
+ if (!liabCols.some(c => c.name === "last_payment_amount")) {
242
+ db.exec(`ALTER TABLE liabilities ADD COLUMN last_payment_amount REAL`);
243
+ db.exec(`ALTER TABLE liabilities ADD COLUMN last_payment_date TEXT`);
244
+ db.exec(`ALTER TABLE liabilities ADD COLUMN credit_limit REAL`);
245
+ db.exec(`ALTER TABLE liabilities ADD COLUMN last_statement_issue_date TEXT`);
246
+ db.exec(`ALTER TABLE liabilities ADD COLUMN is_overdue INTEGER`);
247
+ db.exec(`ALTER TABLE liabilities ADD COLUMN apr_type TEXT`);
248
+ db.exec(`ALTER TABLE liabilities ADD COLUMN maturity_date TEXT`);
249
+ db.exec(`ALTER TABLE liabilities ADD COLUMN loan_type TEXT`);
250
+ db.exec(`ALTER TABLE liabilities ADD COLUMN property_address TEXT`);
251
+ db.exec(`ALTER TABLE liabilities ADD COLUMN escrow_balance REAL`);
252
+ db.exec(`ALTER TABLE liabilities ADD COLUMN loan_status TEXT`);
253
+ db.exec(`ALTER TABLE liabilities ADD COLUMN loan_name TEXT`);
254
+ db.exec(`ALTER TABLE liabilities ADD COLUMN repayment_plan TEXT`);
255
+ db.exec(`ALTER TABLE liabilities ADD COLUMN expected_payoff_date TEXT`);
256
+ db.exec(`ALTER TABLE liabilities ADD COLUMN ytd_interest_paid REAL`);
257
+ db.exec(`ALTER TABLE liabilities ADD COLUMN ytd_principal_paid REAL`);
258
+ }
206
259
  // Migrate: rebuild recurring table to use Plaid stream schema
207
260
  const recCols = db.prepare(`PRAGMA table_info(recurring)`).all();
208
261
  if (!recCols.some(c => c.name === "stream_id")) {
@@ -2,6 +2,8 @@ import type BetterSqlite3 from "libsql";
2
2
  type Database = BetterSqlite3.Database;
3
3
  /** Check if a Plaid API error is "product not supported/enabled" — safe to ignore */
4
4
  export declare function isProductNotSupported(err: unknown): boolean;
5
+ /** Refresh the stored products list and fetch logo if missing */
6
+ export declare function refreshProducts(db: Database, itemId: string, accessToken: string): Promise<string[]>;
5
7
  /** Sync transactions for an institution using Plaid's sync endpoint */
6
8
  export declare function syncTransactions(db: Database, itemId: string, accessToken: string, cursor: string | null): Promise<{
7
9
  added: number;
@@ -20,6 +22,10 @@ export declare function syncRecurring(db: Database, accessToken: string): Promis
20
22
  outflows: number;
21
23
  inflows: number;
22
24
  }>;
25
+ /** Sync investment transactions (buy/sell/dividend history) */
26
+ export declare function syncInvestmentTransactions(db: Database, accessToken: string): Promise<{
27
+ transactions: number;
28
+ }>;
23
29
  /** Sync liabilities (credit, mortgage, student) */
24
30
  export declare function syncLiabilities(db: Database, accessToken: string): Promise<string>;
25
31
  export {};
@@ -1,4 +1,5 @@
1
1
  import { plaidClient } from "./client.js";
2
+ import { CountryCode } from "plaid";
2
3
  /** Check if a Plaid API error is "product not supported/enabled" — safe to ignore */
3
4
  export function isProductNotSupported(err) {
4
5
  const data = err?.response?.data;
@@ -10,8 +11,31 @@ export function isProductNotSupported(err) {
10
11
  "PRODUCTS_NOT_ENABLED",
11
12
  "NO_ACCOUNTS",
12
13
  "PRODUCT_NOT_AVAILABLE",
14
+ "INVALID_PRODUCT",
15
+ "UNAUTHORIZED_PRODUCT",
13
16
  ].includes(data.error_code);
14
17
  }
18
+ /** Refresh the stored products list and fetch logo if missing */
19
+ export async function refreshProducts(db, itemId, accessToken) {
20
+ const resp = await plaidClient.itemGet({ access_token: accessToken });
21
+ const products = resp.data.item.products || [];
22
+ db.prepare(`UPDATE institutions SET products = ? WHERE item_id = ?`).run(JSON.stringify(products), itemId);
23
+ // Fetch logo + primary_color if not already stored
24
+ const inst = db.prepare(`SELECT logo FROM institutions WHERE item_id = ?`).get(itemId);
25
+ if (!inst?.logo && resp.data.item.institution_id) {
26
+ try {
27
+ const { data } = await plaidClient.institutionsGetById({
28
+ institution_id: resp.data.item.institution_id,
29
+ country_codes: [CountryCode.Us],
30
+ options: { include_optional_metadata: true },
31
+ });
32
+ db.prepare(`UPDATE institutions SET logo = ?, primary_color = ? WHERE item_id = ?`)
33
+ .run(data.institution.logo || null, data.institution.primary_color || null, itemId);
34
+ }
35
+ catch { }
36
+ }
37
+ return products;
38
+ }
15
39
  /** Sync transactions for an institution using Plaid's sync endpoint */
16
40
  export async function syncTransactions(db, itemId, accessToken, cursor) {
17
41
  let hasMore = true;
@@ -31,13 +55,14 @@ export async function syncTransactions(db, itemId, accessToken, cursor) {
31
55
  nextCursor = resp.data.next_cursor;
32
56
  }
33
57
  const upsertTx = db.prepare(`
34
- INSERT INTO transactions (transaction_id, account_id, amount, date, name, merchant_name, category, subcategory, pending, iso_currency_code, payment_channel)
35
- VALUES (@transaction_id, @account_id, @amount, @date, @name, @merchant_name, @category, @subcategory, @pending, @iso_currency_code, @payment_channel)
58
+ INSERT INTO transactions (transaction_id, account_id, amount, date, name, merchant_name, category, subcategory, pending, iso_currency_code, payment_channel, logo_url, website)
59
+ VALUES (@transaction_id, @account_id, @amount, @date, @name, @merchant_name, @category, @subcategory, @pending, @iso_currency_code, @payment_channel, @logo_url, @website)
36
60
  ON CONFLICT(transaction_id) DO UPDATE SET
37
61
  amount=excluded.amount, date=excluded.date, name=excluded.name,
38
62
  merchant_name=excluded.merchant_name, category=excluded.category,
39
63
  subcategory=excluded.subcategory, pending=excluded.pending,
40
- payment_channel=excluded.payment_channel
64
+ payment_channel=excluded.payment_channel, logo_url=excluded.logo_url,
65
+ website=excluded.website
41
66
  `);
42
67
  const deleteTx = db.prepare(`DELETE FROM transactions WHERE transaction_id = ?`);
43
68
  const insertMany = db.transaction(() => {
@@ -55,6 +80,8 @@ export async function syncTransactions(db, itemId, accessToken, cursor) {
55
80
  pending: t.pending ? 1 : 0,
56
81
  iso_currency_code: t.iso_currency_code || "USD",
57
82
  payment_channel: t.payment_channel || null,
83
+ logo_url: t.logo_url || null,
84
+ website: t.website || null,
58
85
  });
59
86
  }
60
87
  for (const r of removed) {
@@ -70,12 +97,12 @@ export async function syncTransactions(db, itemId, accessToken, cursor) {
70
97
  export async function syncBalances(db, accessToken) {
71
98
  const resp = await plaidClient.accountsGet({ access_token: accessToken });
72
99
  const upsert = db.prepare(`
73
- INSERT INTO accounts (account_id, item_id, name, official_name, type, subtype, mask, current_balance, available_balance, currency, updated_at)
74
- VALUES (@account_id, @item_id, @name, @official_name, @type, @subtype, @mask, @current_balance, @available_balance, @currency, datetime('now'))
100
+ INSERT INTO accounts (account_id, item_id, name, official_name, type, subtype, mask, current_balance, available_balance, balance_limit, currency, updated_at)
101
+ VALUES (@account_id, @item_id, @name, @official_name, @type, @subtype, @mask, @current_balance, @available_balance, @balance_limit, @currency, datetime('now'))
75
102
  ON CONFLICT(account_id) DO UPDATE SET
76
103
  name=excluded.name, official_name=excluded.official_name,
77
104
  current_balance=excluded.current_balance, available_balance=excluded.available_balance,
78
- updated_at=datetime('now')
105
+ balance_limit=excluded.balance_limit, updated_at=datetime('now')
79
106
  `);
80
107
  const itemId = resp.data.item.item_id;
81
108
  const insertMany = db.transaction(() => {
@@ -90,6 +117,7 @@ export async function syncBalances(db, accessToken) {
90
117
  mask: a.mask || null,
91
118
  current_balance: a.balances.current,
92
119
  available_balance: a.balances.available,
120
+ balance_limit: a.balances.limit ?? null,
93
121
  currency: a.balances.iso_currency_code || "USD",
94
122
  });
95
123
  }
@@ -109,12 +137,13 @@ export async function syncInvestments(db, accessToken) {
109
137
  close_price=excluded.close_price, close_price_as_of=excluded.close_price_as_of
110
138
  `);
111
139
  const upsertHolding = db.prepare(`
112
- INSERT INTO holdings (account_id, security_id, quantity, cost_basis, value, price, price_as_of, updated_at)
113
- VALUES (@account_id, @security_id, @quantity, @cost_basis, @value, @price, @price_as_of, datetime('now'))
140
+ INSERT INTO holdings (account_id, security_id, quantity, cost_basis, value, price, price_as_of, vested_value, vested_quantity, updated_at)
141
+ VALUES (@account_id, @security_id, @quantity, @cost_basis, @value, @price, @price_as_of, @vested_value, @vested_quantity, datetime('now'))
114
142
  ON CONFLICT(account_id, security_id) DO UPDATE SET
115
143
  quantity=excluded.quantity, cost_basis=excluded.cost_basis,
116
144
  value=excluded.value, price=excluded.price,
117
- price_as_of=excluded.price_as_of, updated_at=datetime('now')
145
+ price_as_of=excluded.price_as_of, vested_value=excluded.vested_value,
146
+ vested_quantity=excluded.vested_quantity, updated_at=datetime('now')
118
147
  `);
119
148
  const insertMany = db.transaction(() => {
120
149
  for (const s of resp.data.securities) {
@@ -136,6 +165,8 @@ export async function syncInvestments(db, accessToken) {
136
165
  value: h.institution_value,
137
166
  price: h.institution_price,
138
167
  price_as_of: h.institution_price_as_of || null,
168
+ vested_value: h.vested_value ?? null,
169
+ vested_quantity: h.vested_quantity ?? null,
139
170
  });
140
171
  }
141
172
  });
@@ -194,15 +225,94 @@ export async function syncRecurring(db, accessToken) {
194
225
  inflows: resp.data.inflow_streams.length,
195
226
  };
196
227
  }
228
+ /** Sync investment transactions (buy/sell/dividend history) */
229
+ export async function syncInvestmentTransactions(db, accessToken) {
230
+ const now = new Date();
231
+ const startDate = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
232
+ .toISOString().slice(0, 10);
233
+ const endDate = now.toISOString().slice(0, 10);
234
+ let offset = 0;
235
+ const count = 500;
236
+ let totalFetched = 0;
237
+ const upsertSecurity = db.prepare(`
238
+ INSERT INTO securities (security_id, ticker, name, type, close_price, close_price_as_of)
239
+ VALUES (@security_id, @ticker, @name, @type, @close_price, @close_price_as_of)
240
+ ON CONFLICT(security_id) DO UPDATE SET
241
+ close_price=excluded.close_price, close_price_as_of=excluded.close_price_as_of
242
+ `);
243
+ const upsertTx = db.prepare(`
244
+ INSERT INTO investment_transactions (investment_transaction_id, account_id, security_id, date, name, quantity, amount, price, fees, type, subtype, iso_currency_code)
245
+ VALUES (@investment_transaction_id, @account_id, @security_id, @date, @name, @quantity, @amount, @price, @fees, @type, @subtype, @iso_currency_code)
246
+ ON CONFLICT(investment_transaction_id) DO UPDATE SET
247
+ amount=excluded.amount, quantity=excluded.quantity, price=excluded.price,
248
+ fees=excluded.fees, name=excluded.name
249
+ `);
250
+ while (true) {
251
+ const resp = await plaidClient.investmentsTransactionsGet({
252
+ access_token: accessToken,
253
+ start_date: startDate,
254
+ end_date: endDate,
255
+ options: { offset, count },
256
+ });
257
+ const batch = db.transaction(() => {
258
+ for (const s of resp.data.securities) {
259
+ upsertSecurity.run({
260
+ security_id: s.security_id,
261
+ ticker: s.ticker_symbol || null,
262
+ name: s.name || "Unknown",
263
+ type: s.type || null,
264
+ close_price: s.close_price || null,
265
+ close_price_as_of: s.close_price_as_of || null,
266
+ });
267
+ }
268
+ for (const t of resp.data.investment_transactions) {
269
+ upsertTx.run({
270
+ investment_transaction_id: t.investment_transaction_id,
271
+ account_id: t.account_id,
272
+ security_id: t.security_id || null,
273
+ date: t.date,
274
+ name: t.name,
275
+ quantity: t.quantity,
276
+ amount: t.amount,
277
+ price: t.price,
278
+ fees: t.fees || null,
279
+ type: t.type || null,
280
+ subtype: t.subtype || null,
281
+ iso_currency_code: t.iso_currency_code || null,
282
+ });
283
+ }
284
+ });
285
+ batch();
286
+ totalFetched += resp.data.investment_transactions.length;
287
+ if (totalFetched >= resp.data.total_investment_transactions)
288
+ break;
289
+ offset += count;
290
+ }
291
+ return { transactions: totalFetched };
292
+ }
197
293
  /** Sync liabilities (credit, mortgage, student) */
198
294
  export async function syncLiabilities(db, accessToken) {
199
295
  const resp = await plaidClient.liabilitiesGet({ access_token: accessToken });
200
296
  const upsert = db.prepare(`
201
- INSERT INTO liabilities (account_id, type, interest_rate, origination_date, original_balance, current_balance, minimum_payment, next_payment_due, updated_at)
202
- VALUES (@account_id, @type, @interest_rate, @origination_date, @original_balance, @current_balance, @minimum_payment, @next_payment_due, datetime('now'))
297
+ INSERT INTO liabilities (account_id, type, interest_rate, origination_date, original_balance, current_balance, minimum_payment, next_payment_due,
298
+ last_payment_amount, last_payment_date, credit_limit, last_statement_issue_date, is_overdue, apr_type,
299
+ maturity_date, loan_type, property_address, escrow_balance,
300
+ loan_status, loan_name, repayment_plan, expected_payoff_date, ytd_interest_paid, ytd_principal_paid, updated_at)
301
+ VALUES (@account_id, @type, @interest_rate, @origination_date, @original_balance, @current_balance, @minimum_payment, @next_payment_due,
302
+ @last_payment_amount, @last_payment_date, @credit_limit, @last_statement_issue_date, @is_overdue, @apr_type,
303
+ @maturity_date, @loan_type, @property_address, @escrow_balance,
304
+ @loan_status, @loan_name, @repayment_plan, @expected_payoff_date, @ytd_interest_paid, @ytd_principal_paid, datetime('now'))
203
305
  ON CONFLICT(account_id, type) DO UPDATE SET
204
306
  interest_rate=excluded.interest_rate, current_balance=excluded.current_balance,
205
307
  minimum_payment=excluded.minimum_payment, next_payment_due=excluded.next_payment_due,
308
+ last_payment_amount=excluded.last_payment_amount, last_payment_date=excluded.last_payment_date,
309
+ credit_limit=excluded.credit_limit, last_statement_issue_date=excluded.last_statement_issue_date,
310
+ is_overdue=excluded.is_overdue, apr_type=excluded.apr_type,
311
+ maturity_date=excluded.maturity_date, loan_type=excluded.loan_type,
312
+ property_address=excluded.property_address, escrow_balance=excluded.escrow_balance,
313
+ loan_status=excluded.loan_status, loan_name=excluded.loan_name,
314
+ repayment_plan=excluded.repayment_plan, expected_payoff_date=excluded.expected_payoff_date,
315
+ ytd_interest_paid=excluded.ytd_interest_paid, ytd_principal_paid=excluded.ytd_principal_paid,
206
316
  updated_at=datetime('now')
207
317
  `);
208
318
  const insertMany = db.transaction(() => {
@@ -217,6 +327,22 @@ export async function syncLiabilities(db, accessToken) {
217
327
  current_balance: c.last_statement_balance,
218
328
  minimum_payment: c.minimum_payment_amount,
219
329
  next_payment_due: c.next_payment_due_date || null,
330
+ last_payment_amount: c.last_payment_amount || null,
331
+ last_payment_date: c.last_payment_date || null,
332
+ credit_limit: null, // comes from accounts.balance_limit
333
+ last_statement_issue_date: c.last_statement_issue_date || null,
334
+ is_overdue: c.is_overdue ? 1 : 0,
335
+ apr_type: c.aprs?.[0]?.apr_type || null,
336
+ maturity_date: null,
337
+ loan_type: null,
338
+ property_address: null,
339
+ escrow_balance: null,
340
+ loan_status: null,
341
+ loan_name: null,
342
+ repayment_plan: null,
343
+ expected_payoff_date: null,
344
+ ytd_interest_paid: null,
345
+ ytd_principal_paid: null,
220
346
  });
221
347
  }
222
348
  const mortgage = resp.data.liabilities.mortgage || [];
@@ -227,9 +353,25 @@ export async function syncLiabilities(db, accessToken) {
227
353
  interest_rate: m.interest_rate?.percentage || null,
228
354
  origination_date: m.origination_date || null,
229
355
  original_balance: m.origination_principal_amount || null,
230
- current_balance: m.last_payment_amount || null,
356
+ current_balance: null, // actual balance in accounts.current_balance
231
357
  minimum_payment: m.last_payment_amount || null,
232
358
  next_payment_due: m.next_payment_due_date || null,
359
+ last_payment_amount: m.last_payment_amount || null,
360
+ last_payment_date: m.last_payment_date || null,
361
+ credit_limit: null,
362
+ last_statement_issue_date: null,
363
+ is_overdue: null,
364
+ apr_type: null,
365
+ maturity_date: m.maturity_date || null,
366
+ loan_type: m.loan_type_description || null,
367
+ property_address: m.property_address ? JSON.stringify(m.property_address) : null,
368
+ escrow_balance: m.escrow_balance || null,
369
+ loan_status: null,
370
+ loan_name: null,
371
+ repayment_plan: null,
372
+ expected_payoff_date: null,
373
+ ytd_interest_paid: null,
374
+ ytd_principal_paid: null,
233
375
  });
234
376
  }
235
377
  const student = resp.data.liabilities.student || [];
@@ -240,9 +382,25 @@ export async function syncLiabilities(db, accessToken) {
240
382
  interest_rate: s.interest_rate_percentage || null,
241
383
  origination_date: s.origination_date || null,
242
384
  original_balance: s.origination_principal_amount || null,
243
- current_balance: s.last_payment_amount || null,
385
+ current_balance: null, // actual balance in accounts.current_balance
244
386
  minimum_payment: s.minimum_payment_amount || null,
245
387
  next_payment_due: s.next_payment_due_date || null,
388
+ last_payment_amount: s.last_payment_amount || null,
389
+ last_payment_date: s.last_payment_date || null,
390
+ credit_limit: null,
391
+ last_statement_issue_date: null,
392
+ is_overdue: null,
393
+ apr_type: null,
394
+ maturity_date: null,
395
+ loan_type: null,
396
+ property_address: null,
397
+ escrow_balance: null,
398
+ loan_status: s.loan_status?.type || null,
399
+ loan_name: s.loan_name || null,
400
+ repayment_plan: s.repayment_plan?.description || null,
401
+ expected_payoff_date: s.expected_payoff_date || null,
402
+ ytd_interest_paid: s.ytd_interest_paid || null,
403
+ ytd_principal_paid: s.ytd_principal_paid || null,
246
404
  });
247
405
  }
248
406
  });
@@ -8,48 +8,63 @@
8
8
  <style>
9
9
  * { margin: 0; padding: 0; box-sizing: border-box; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; }
10
10
  body {
11
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
12
- background: #fafaf9;
13
- color: #1c1917;
11
+ font-family: 'SF Mono', SFMono-Regular, ui-monospace, 'DejaVu Sans Mono', Menlo, Consolas, monospace;
12
+ background: #202023;
13
+ color: #ffffff;
14
14
  display: flex;
15
+ flex-direction: column;
15
16
  align-items: center;
16
17
  justify-content: center;
17
18
  min-height: 100vh;
18
19
  padding: 24px;
19
20
  }
20
- .card {
21
- background: #fff;
22
- border: 1px solid rgba(214, 211, 209, 0.6);
23
- border-radius: 16px;
24
- padding: 48px 40px;
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 {
29
+ text-align: center;
25
30
  max-width: 400px;
26
31
  width: 100%;
27
- text-align: center;
28
32
  }
29
- .logo { margin-bottom: 32px; }
30
- .logo img { height: 24px; }
31
- h1 { font-size: 20px; font-weight: 600; margin-bottom: 8px; color: #1c1917; }
32
- p { font-size: 15px; color: #78716c; margin-bottom: 24px; line-height: 1.6; }
33
+ .trust {
34
+ position: absolute;
35
+ bottom: 28px;
36
+ left: 50%;
37
+ transform: translateX(-50%);
38
+ display: flex;
39
+ align-items: center;
40
+ gap: 6px;
41
+ color: #4a4a4f;
42
+ font-size: 11px;
43
+ }
44
+ .trust svg { width: 12px; height: 12px; stroke: #4a4a4f; fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
45
+ h1 { font-size: 16px; font-weight: 500; margin-bottom: 8px; color: #ffffff; letter-spacing: -0.01em; }
46
+ p { font-size: 13px; color: #9c9da1; margin-bottom: 24px; line-height: 1.6; }
33
47
  button {
34
- background: #1c1917;
35
- color: #fff;
48
+ background: #ffffff;
49
+ color: #202023;
36
50
  border: none;
37
- padding: 14px 32px;
38
- border-radius: 9999px;
39
- font-size: 15px;
51
+ padding: 12px 28px;
52
+ border-radius: 6px;
53
+ font-size: 13px;
40
54
  font-weight: 500;
55
+ font-family: inherit;
41
56
  cursor: pointer;
42
57
  width: 100%;
43
- transition: background 0.2s;
58
+ transition: opacity 0.2s;
44
59
  }
45
- button:hover { background: #292524; }
46
- button:disabled { background: #d6d3d1; cursor: not-allowed; }
47
- .success { color: #6ab318; font-weight: 600; }
48
- .error { color: #dc3545; font-size: 14px; margin-top: 16px; }
60
+ button:hover { opacity: 0.85; }
61
+ button:disabled { opacity: 0.3; cursor: not-allowed; }
62
+ .success { color: #34d399; font-weight: 500; }
63
+ .error { color: #f87171; font-size: 13px; margin-top: 16px; }
49
64
  .checkmark {
50
- width: 48px;
51
- height: 48px;
52
- border: 2.5px solid #6ab318;
65
+ width: 40px;
66
+ height: 40px;
67
+ border: 2px solid #34d399;
53
68
  border-radius: 50%;
54
69
  display: flex;
55
70
  align-items: center;
@@ -57,39 +72,40 @@
57
72
  margin: 0 auto 20px;
58
73
  }
59
74
  .checkmark svg {
60
- width: 24px;
61
- height: 24px;
62
- stroke: #6ab318;
75
+ width: 20px;
76
+ height: 20px;
77
+ stroke: #34d399;
63
78
  fill: none;
64
79
  stroke-width: 2.5;
65
80
  stroke-linecap: round;
66
81
  stroke-linejoin: round;
67
82
  }
68
83
  .institution-logo {
69
- width: 64px;
70
- height: 64px;
71
- border-radius: 16px;
84
+ width: 48px;
85
+ height: 48px;
86
+ border-radius: 10px;
72
87
  object-fit: contain;
73
88
  margin-bottom: 16px;
74
89
  }
75
90
  .spinner {
76
91
  display: inline-block;
77
- width: 20px;
78
- height: 20px;
79
- border: 2px solid #d6d3d1;
80
- border-top-color: #78716c;
92
+ width: 24px;
93
+ height: 24px;
94
+ border: 2px solid rgba(255, 255, 255, 0.1);
95
+ border-top-color: #9c9da1;
81
96
  border-radius: 50%;
82
- animation: spin 0.6s linear infinite;
83
- margin-bottom: 16px;
97
+ animation: spin 0.8s linear infinite;
98
+ margin-bottom: 20px;
84
99
  }
85
100
  @keyframes spin { to { transform: rotate(360deg); } }
86
101
  </style>
87
102
  </head>
88
103
  <body>
89
- <div class="card">
90
- <div class="logo"><img src="/ray-logo-dark.png" alt="Ray"></div>
104
+ <div class="top-logo"><img src="/ray-logo-dark.png" alt="Ray"></div>
105
+ <div class="container">
91
106
  <div id="connect-view">
92
107
  <div class="spinner"></div>
108
+ <h1>Connecting</h1>
93
109
  <p>Opening Plaid...</p>
94
110
  <div id="error" class="error" style="display:none"></div>
95
111
  </div>
@@ -97,9 +113,13 @@
97
113
  <div class="checkmark"><svg viewBox="0 0 24 24"><polyline points="4 12 10 18 20 6"/></svg></div>
98
114
  <img id="institution-logo" class="institution-logo" style="display:none" alt="">
99
115
  <h1 class="success" id="success-title">Account Connected</h1>
100
- <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>
116
+ <p>Syncing transactions. Data stays on your machine. You can close this page.</p>
101
117
  </div>
102
118
  </div>
119
+ <div class="trust">
120
+ <svg viewBox="0 0 24 24"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
121
+ <span>End-to-end encrypted</span>
122
+ </div>
103
123
 
104
124
  <script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
105
125
  <script>
@@ -132,7 +152,7 @@
132
152
  linkHandler = Plaid.create({
133
153
  token: link_token,
134
154
  onSuccess: async (publicToken, metadata) => {
135
- document.getElementById('connect-view').innerHTML = '<p>Linking your account...</p>';
155
+ document.getElementById('connect-view').innerHTML = '<div class="spinner"></div><h1>Linking</h1><p>Exchanging credentials...</p>';
136
156
  try {
137
157
  const resp = await fetch('/api/exchange', {
138
158
  method: 'POST',
@@ -162,11 +182,12 @@
162
182
  }
163
183
  },
164
184
  onExit: (err) => {
185
+ document.getElementById('connect-view').style.display = 'block';
165
186
  if (err) showError('Connection cancelled. You can try again.');
166
187
  },
167
188
  });
168
- // Auto-open Plaid Link
169
189
  linkHandler.open();
190
+ document.getElementById('connect-view').style.display = 'none';
170
191
  } catch (e) {
171
192
  showError(e.message || 'This link has expired. Please run "ray link" again.');
172
193
  }
package/dist/server.js CHANGED
@@ -3,7 +3,7 @@ import { fileURLToPath } from "url";
3
3
  import { dirname, resolve } from "path";
4
4
  import { randomUUID } from "crypto";
5
5
  import { createLinkToken, exchangeToken } from "./plaid/link.js";
6
- import { syncBalances, syncTransactions, syncInvestments, syncLiabilities, syncRecurring, isProductNotSupported } from "./plaid/sync.js";
6
+ import { syncBalances, syncTransactions, syncInvestments, syncInvestmentTransactions, syncLiabilities, syncRecurring, isProductNotSupported } from "./plaid/sync.js";
7
7
  import { plaidClient } from "./plaid/client.js";
8
8
  import { CountryCode } from "plaid";
9
9
  import { encryptPlaidToken } from "./db/encryption.js";
@@ -94,33 +94,51 @@ export function startLinkServer() {
94
94
  return;
95
95
  }
96
96
  const encryptedToken = encryptPlaidToken(accessToken, config.plaidTokenSecret);
97
+ // Fetch actual enabled products from Plaid
98
+ const itemResp = await plaidClient.itemGet({ access_token: accessToken });
99
+ const products = (itemResp.data.item.products || []);
97
100
  db.prepare(`INSERT INTO institutions (item_id, access_token, name, products)
98
101
  VALUES (?, ?, ?, ?)
99
- ON CONFLICT(item_id) DO UPDATE SET access_token = excluded.access_token`).run(itemId, encryptedToken, institution_name || "Account", JSON.stringify(["transactions", "investments", "liabilities"]));
102
+ ON CONFLICT(item_id) DO UPDATE SET access_token = excluded.access_token, products = excluded.products`).run(itemId, encryptedToken, institution_name || "Account", JSON.stringify(products));
100
103
  // Trigger initial sync (Plaid may not have data ready immediately)
101
104
  const runSync = async () => {
102
105
  await syncBalances(db, accessToken);
103
- await syncTransactions(db, itemId, accessToken, null);
104
- try {
105
- await syncInvestments(db, accessToken);
106
- }
107
- catch (e) {
108
- if (!isProductNotSupported(e))
109
- throw e;
106
+ if (products.includes("transactions")) {
107
+ await syncTransactions(db, itemId, accessToken, null);
110
108
  }
111
- try {
112
- await syncLiabilities(db, accessToken);
113
- }
114
- catch (e) {
115
- if (!isProductNotSupported(e))
116
- throw e;
109
+ if (products.includes("investments")) {
110
+ try {
111
+ await syncInvestments(db, accessToken);
112
+ }
113
+ catch (e) {
114
+ if (!isProductNotSupported(e))
115
+ throw e;
116
+ }
117
+ try {
118
+ await syncInvestmentTransactions(db, accessToken);
119
+ }
120
+ catch (e) {
121
+ if (!isProductNotSupported(e))
122
+ throw e;
123
+ }
117
124
  }
118
- try {
119
- await syncRecurring(db, accessToken);
125
+ if (products.includes("liabilities")) {
126
+ try {
127
+ await syncLiabilities(db, accessToken);
128
+ }
129
+ catch (e) {
130
+ if (!isProductNotSupported(e))
131
+ throw e;
132
+ }
120
133
  }
121
- catch (e) {
122
- if (!isProductNotSupported(e))
123
- throw e;
134
+ if (products.includes("transactions")) {
135
+ try {
136
+ await syncRecurring(db, accessToken);
137
+ }
138
+ catch (e) {
139
+ if (!isProductNotSupported(e))
140
+ throw e;
141
+ }
124
142
  }
125
143
  };
126
144
  try {
@@ -149,7 +167,7 @@ export function startLinkServer() {
149
167
  }
150
168
  }, 30_000);
151
169
  }
152
- // Fetch institution logo
170
+ // Fetch and store institution logo + brand color
153
171
  let institutionLogo = null;
154
172
  if (req.body.institution_id) {
155
173
  try {
@@ -159,6 +177,9 @@ export function startLinkServer() {
159
177
  options: { include_optional_metadata: true },
160
178
  });
161
179
  institutionLogo = data.institution.logo || null;
180
+ const primaryColor = data.institution.primary_color || null;
181
+ db.prepare(`UPDATE institutions SET logo = ?, primary_color = ? WHERE item_id = ?`)
182
+ .run(institutionLogo, primaryColor, itemId);
162
183
  }
163
184
  catch { }
164
185
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ray-finance",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
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",
@@ -39,15 +39,16 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@anthropic-ai/sdk": "^0.74.0",
42
- "libsql": "^0.5.0",
43
42
  "chalk": "^5.3.0",
44
43
  "commander": "^13.0.0",
45
44
  "dotenv": "^16.4.0",
46
45
  "express": "^4.21.0",
47
46
  "inquirer": "^12.0.0",
47
+ "libsql": "^0.5.0",
48
48
  "open": "^10.0.0",
49
49
  "ora": "^8.0.0",
50
- "plaid": "^28.0.0"
50
+ "plaid": "^28.0.0",
51
+ "terminal-image": "^4.2.0"
51
52
  },
52
53
  "devDependencies": {
53
54
  "@types/better-sqlite3": "^7.6.0",