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.
- package/dist/cli/commands.d.ts +1 -1
- package/dist/cli/commands.js +34 -14
- package/dist/cli/format.d.ts +4 -0
- package/dist/cli/format.js +23 -0
- package/dist/cli/index.js +1 -1
- package/dist/daily-sync.js +38 -18
- package/dist/db/schema.js +53 -0
- package/dist/plaid/sync.d.ts +6 -0
- package/dist/plaid/sync.js +171 -13
- package/dist/public/link.html +64 -43
- package/dist/server.js +42 -21
- package/package.json +4 -3
package/dist/cli/commands.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/commands.js
CHANGED
|
@@ -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
|
-
|
|
51
|
+
// Group rows by institution
|
|
52
|
+
const groups = new Map();
|
|
52
53
|
for (const row of institutions) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
}
|
package/dist/cli/format.d.ts
CHANGED
|
@@ -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;
|
package/dist/cli/format.js
CHANGED
|
@@ -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
package/dist/daily-sync.js
CHANGED
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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")) {
|
package/dist/plaid/sync.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/plaid/sync.js
CHANGED
|
@@ -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,
|
|
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,
|
|
202
|
-
|
|
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:
|
|
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:
|
|
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
|
});
|
package/dist/public/link.html
CHANGED
|
@@ -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: -
|
|
12
|
-
background: #
|
|
13
|
-
color: #
|
|
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
|
-
.
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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: #
|
|
35
|
-
color: #
|
|
48
|
+
background: #ffffff;
|
|
49
|
+
color: #202023;
|
|
36
50
|
border: none;
|
|
37
|
-
padding:
|
|
38
|
-
border-radius:
|
|
39
|
-
font-size:
|
|
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:
|
|
58
|
+
transition: opacity 0.2s;
|
|
44
59
|
}
|
|
45
|
-
button:hover {
|
|
46
|
-
button:disabled {
|
|
47
|
-
.success { color: #
|
|
48
|
-
.error { color: #
|
|
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:
|
|
51
|
-
height:
|
|
52
|
-
border:
|
|
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:
|
|
61
|
-
height:
|
|
62
|
-
stroke: #
|
|
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:
|
|
70
|
-
height:
|
|
71
|
-
border-radius:
|
|
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:
|
|
78
|
-
height:
|
|
79
|
-
border: 2px solid
|
|
80
|
-
border-top-color: #
|
|
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.
|
|
83
|
-
margin-bottom:
|
|
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="
|
|
90
|
-
|
|
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>
|
|
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>
|
|
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(
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
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",
|