ray-finance 0.3.1 → 0.3.2
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 +2 -0
- package/dist/cli/commands.js +135 -0
- package/dist/cli/index.js +18 -0
- package/dist/daily-sync.js +10 -0
- package/dist/property.d.ts +23 -0
- package/dist/property.js +80 -0
- package/dist/public/link.html +11 -18
- package/dist/queries/index.js +4 -4
- package/dist/server.js +25 -1
- package/package.json +1 -1
package/dist/cli/commands.d.ts
CHANGED
|
@@ -11,4 +11,6 @@ export declare function showSpending(period?: string): Promise<void>;
|
|
|
11
11
|
export declare function showBudgets(): void;
|
|
12
12
|
export declare function showGoals(): void;
|
|
13
13
|
export declare function showScore(): void;
|
|
14
|
+
export declare function runAdd(): Promise<void>;
|
|
15
|
+
export declare function runRemove(): Promise<void>;
|
|
14
16
|
export declare function showAlerts(): void;
|
package/dist/cli/commands.js
CHANGED
|
@@ -5,6 +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 { addManualAccount, getManualAccounts, removeManualAccount, scrapeRedfinEstimate } from "../property.js";
|
|
8
9
|
import { heading, progressBar, formatMoney, formatMoneyColored, dim, formatDuration, formatError, renderLogo, institutionName } from "./format.js";
|
|
9
10
|
export async function runSync() {
|
|
10
11
|
const ora = (await import("ora")).default;
|
|
@@ -26,6 +27,7 @@ export async function runSync() {
|
|
|
26
27
|
export async function runLink() {
|
|
27
28
|
const open = (await import("open")).default;
|
|
28
29
|
const ora = (await import("ora")).default;
|
|
30
|
+
const readline = await import("readline");
|
|
29
31
|
const { url, waitForComplete, stop } = startLinkServer();
|
|
30
32
|
console.log(`\n${heading("Link Account")}\n`);
|
|
31
33
|
console.log(`Opening Plaid Link in your browser...\n`);
|
|
@@ -35,6 +37,37 @@ export async function runLink() {
|
|
|
35
37
|
await waitForComplete();
|
|
36
38
|
stop();
|
|
37
39
|
spinner.succeed("Bank account linked successfully!");
|
|
40
|
+
// Check if a mortgage was linked and we don't already have a property account
|
|
41
|
+
const db = getDb();
|
|
42
|
+
const hasMortgage = db.prepare(`SELECT 1 FROM accounts WHERE type = 'loan' AND subtype = 'mortgage' LIMIT 1`).get();
|
|
43
|
+
const hasProperty = db.prepare(`SELECT 1 FROM accounts WHERE type = 'other' AND subtype = 'property' LIMIT 1`).get();
|
|
44
|
+
if (hasMortgage && !hasProperty) {
|
|
45
|
+
console.log(`\n${dim("Mortgage detected.")} Track your home value for accurate net worth.`);
|
|
46
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
47
|
+
const ask = (q) => new Promise(resolve => rl.question(q, resolve));
|
|
48
|
+
const listingUrl = (await ask(`${dim("Paste a Redfin URL (or press Enter to skip):")} `)).trim();
|
|
49
|
+
if (listingUrl) {
|
|
50
|
+
const name = (await ask(`${dim("Name (e.g. Primary Residence):")} `)).trim() || "Primary Residence";
|
|
51
|
+
rl.close();
|
|
52
|
+
const propSpinner = ora("Fetching home value...").start();
|
|
53
|
+
try {
|
|
54
|
+
const value = await scrapeRedfinEstimate(listingUrl);
|
|
55
|
+
if (value) {
|
|
56
|
+
addManualAccount(db, name, "asset", value, listingUrl);
|
|
57
|
+
propSpinner.succeed(`${name}: ${rawFormatMoney(value)} — updates automatically on sync.`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
propSpinner.fail("Could not determine home value from that URL. Try 'ray add' later.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
propSpinner.fail("Failed to fetch home value. Try 'ray add' later.");
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
rl.close();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
38
71
|
}
|
|
39
72
|
export async function showAccounts() {
|
|
40
73
|
const db = getDb();
|
|
@@ -239,6 +272,108 @@ export function showScore() {
|
|
|
239
272
|
}
|
|
240
273
|
console.log();
|
|
241
274
|
}
|
|
275
|
+
export async function runAdd() {
|
|
276
|
+
const ora = (await import("ora")).default;
|
|
277
|
+
const inquirer = (await import("inquirer")).default;
|
|
278
|
+
const db = getDb();
|
|
279
|
+
const theme = {
|
|
280
|
+
prefix: { idle: " ", done: chalk.green(" ✓") },
|
|
281
|
+
style: { highlight: (text) => chalk.yellowBright(text) },
|
|
282
|
+
};
|
|
283
|
+
console.log(`\n${heading("Add Account")}`);
|
|
284
|
+
console.log(dim(" Track something not linked via Plaid — a home, car, crypto, loan, etc.\n"));
|
|
285
|
+
const { name } = await inquirer.prompt([{ theme,
|
|
286
|
+
type: "input",
|
|
287
|
+
name: "name",
|
|
288
|
+
message: "Name",
|
|
289
|
+
validate: (v) => v.trim() ? true : "Required",
|
|
290
|
+
}]);
|
|
291
|
+
const { type } = await inquirer.prompt([{ theme,
|
|
292
|
+
type: "list",
|
|
293
|
+
name: "type",
|
|
294
|
+
message: "Type",
|
|
295
|
+
choices: [
|
|
296
|
+
{ name: "Asset — something you own (adds to net worth)", value: "asset" },
|
|
297
|
+
{ name: "Liability — something you owe (subtracts from net worth)", value: "liability" },
|
|
298
|
+
],
|
|
299
|
+
}]);
|
|
300
|
+
let finalBalance = 0;
|
|
301
|
+
let listingUrl;
|
|
302
|
+
// For assets: offer Redfin auto-tracking
|
|
303
|
+
if (type === "asset") {
|
|
304
|
+
const { redfin } = await inquirer.prompt([{ theme,
|
|
305
|
+
type: "input",
|
|
306
|
+
name: "redfin",
|
|
307
|
+
message: `Redfin URL ${dim("(optional — auto-tracks home value)")}`,
|
|
308
|
+
}]);
|
|
309
|
+
const url = redfin.trim();
|
|
310
|
+
if (url) {
|
|
311
|
+
try {
|
|
312
|
+
const parsed = new URL(url);
|
|
313
|
+
if (!parsed.hostname.includes("redfin")) {
|
|
314
|
+
console.log(chalk.yellow(" Only Redfin URLs are supported."));
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
listingUrl = url;
|
|
318
|
+
const spinner = ora("Fetching Redfin Estimate...").start();
|
|
319
|
+
const scraped = await scrapeRedfinEstimate(url);
|
|
320
|
+
if (scraped) {
|
|
321
|
+
finalBalance = scraped;
|
|
322
|
+
spinner.succeed(`Redfin Estimate: ${chalk.bold(rawFormatMoney(scraped))} ${dim("— updates on each sync")}`);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
spinner.warn("Could not fetch estimate.");
|
|
326
|
+
listingUrl = undefined;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
catch {
|
|
331
|
+
console.log(chalk.yellow(" Invalid URL."));
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// Manual value if no Redfin
|
|
336
|
+
if (!listingUrl) {
|
|
337
|
+
const { balance } = await inquirer.prompt([{ theme,
|
|
338
|
+
type: "input",
|
|
339
|
+
name: "balance",
|
|
340
|
+
message: "Current value ($)",
|
|
341
|
+
validate: (v) => {
|
|
342
|
+
const n = parseFloat(v.replace(/[$,]/g, ""));
|
|
343
|
+
return isNaN(n) ? "Enter a number" : true;
|
|
344
|
+
},
|
|
345
|
+
}]);
|
|
346
|
+
finalBalance = parseFloat(balance.replace(/[$,]/g, ""));
|
|
347
|
+
}
|
|
348
|
+
addManualAccount(db, name.trim(), type, finalBalance, listingUrl);
|
|
349
|
+
const label = type === "asset" ? chalk.green("asset") : chalk.red("liability");
|
|
350
|
+
console.log(`\n ${chalk.green("+")} ${chalk.bold(name.trim())} ${rawFormatMoney(finalBalance)} ${label}\n`);
|
|
351
|
+
}
|
|
352
|
+
export async function runRemove() {
|
|
353
|
+
const readline = await import("readline");
|
|
354
|
+
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.");
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
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}`);
|
|
366
|
+
}
|
|
367
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
368
|
+
const answer = (await new Promise(resolve => rl.question(`\n Remove which? (number, or Enter to cancel): `, resolve))).trim();
|
|
369
|
+
rl.close();
|
|
370
|
+
const idx = parseInt(answer, 10) - 1;
|
|
371
|
+
if (isNaN(idx) || idx < 0 || idx >= accounts.length)
|
|
372
|
+
return;
|
|
373
|
+
removeManualAccount(db, accounts[idx].account_id);
|
|
374
|
+
console.log(chalk.green(`\n Removed ${accounts[idx].name}.`));
|
|
375
|
+
console.log();
|
|
376
|
+
}
|
|
242
377
|
export function showAlerts() {
|
|
243
378
|
const db = getDb();
|
|
244
379
|
const alerts = generateAlerts(db);
|
package/dist/cli/index.js
CHANGED
|
@@ -60,6 +60,22 @@ program
|
|
|
60
60
|
const { runLink } = await import("./commands.js");
|
|
61
61
|
await runLink();
|
|
62
62
|
});
|
|
63
|
+
program
|
|
64
|
+
.command("add")
|
|
65
|
+
.description("Add a manual account (home, car, crypto, etc.)")
|
|
66
|
+
.action(async () => {
|
|
67
|
+
ensureConfigured();
|
|
68
|
+
const { runAdd } = await import("./commands.js");
|
|
69
|
+
await runAdd();
|
|
70
|
+
});
|
|
71
|
+
program
|
|
72
|
+
.command("remove")
|
|
73
|
+
.description("Remove a manual account")
|
|
74
|
+
.action(async () => {
|
|
75
|
+
ensureConfigured();
|
|
76
|
+
const { runRemove } = await import("./commands.js");
|
|
77
|
+
await runRemove();
|
|
78
|
+
});
|
|
63
79
|
program
|
|
64
80
|
.command("accounts")
|
|
65
81
|
.description("Show linked accounts and balances")
|
|
@@ -221,6 +237,8 @@ program.configureHelp({
|
|
|
221
237
|
formatHelp: () => helpScreen([
|
|
222
238
|
{ name: "setup", desc: "Configure Ray (API keys, preferences)" },
|
|
223
239
|
{ name: "link", desc: "Link a new financial account via Plaid" },
|
|
240
|
+
{ name: "add", desc: "Add a manual account (home, car, crypto, etc.)" },
|
|
241
|
+
{ name: "remove", desc: "Remove a manual account" },
|
|
224
242
|
{ name: "sync", desc: "Sync transactions from linked banks" },
|
|
225
243
|
{ name: "accounts", desc: "Show linked accounts and balances" },
|
|
226
244
|
{ name: "status", desc: "Show financial overview" },
|
package/dist/daily-sync.js
CHANGED
|
@@ -3,6 +3,7 @@ import { calculateDailyScore, checkAchievements } from "./scoring/index.js";
|
|
|
3
3
|
import { decryptPlaidToken } from "./db/encryption.js";
|
|
4
4
|
import { config } from "./config.js";
|
|
5
5
|
import { institutionName } from "./cli/format.js";
|
|
6
|
+
import { refreshPropertyValues, hasListingUrls } from "./property.js";
|
|
6
7
|
/** Run the daily sync for a single database */
|
|
7
8
|
export async function runDailySync(db) {
|
|
8
9
|
const institutions = db
|
|
@@ -98,6 +99,15 @@ export async function runDailySync(db) {
|
|
|
98
99
|
console.error(` Error syncing ${inst.name}: ${err.message}`);
|
|
99
100
|
}
|
|
100
101
|
}
|
|
102
|
+
// Refresh property values from listing URLs if configured
|
|
103
|
+
if (hasListingUrls(db)) {
|
|
104
|
+
try {
|
|
105
|
+
await refreshPropertyValues(db);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// Non-fatal
|
|
109
|
+
}
|
|
110
|
+
}
|
|
101
111
|
// Snapshot net worth
|
|
102
112
|
const assets = db
|
|
103
113
|
.prepare(`SELECT COALESCE(SUM(current_balance), 0) as total FROM accounts WHERE type IN ('depository', 'investment', 'other')`)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type BetterSqlite3 from "libsql";
|
|
2
|
+
type Database = BetterSqlite3.Database;
|
|
3
|
+
/** Scrape the Redfin Estimate from a Redfin listing URL */
|
|
4
|
+
export declare function scrapeRedfinEstimate(url: string): Promise<number | null>;
|
|
5
|
+
/** Add a manual account */
|
|
6
|
+
export declare function addManualAccount(db: Database, name: string, type: "asset" | "liability", balance: number, listingUrl?: string): {
|
|
7
|
+
accountId: string;
|
|
8
|
+
};
|
|
9
|
+
/** Remove a manual account */
|
|
10
|
+
export declare function removeManualAccount(db: Database, id: string): void;
|
|
11
|
+
/** List all manual accounts */
|
|
12
|
+
export declare function getManualAccounts(db: Database): {
|
|
13
|
+
account_id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
type: string;
|
|
16
|
+
current_balance: number;
|
|
17
|
+
listing_url: string | null;
|
|
18
|
+
}[];
|
|
19
|
+
/** Refresh all property values from stored listing URLs (called during daily sync) */
|
|
20
|
+
export declare function refreshPropertyValues(db: Database): Promise<void>;
|
|
21
|
+
/** Check if any listing URLs are configured */
|
|
22
|
+
export declare function hasListingUrls(db: Database): boolean;
|
|
23
|
+
export {};
|
package/dist/property.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
const MANUAL_ITEM_ID = "manual-assets";
|
|
3
|
+
const LISTING_URL_PREFIX = "listing_url:";
|
|
4
|
+
function accountId(name) {
|
|
5
|
+
const hash = createHash("sha256").update(name).digest("hex").slice(0, 8);
|
|
6
|
+
return `manual-${hash}`;
|
|
7
|
+
}
|
|
8
|
+
/** Scrape the Redfin Estimate from a Redfin listing URL */
|
|
9
|
+
export async function scrapeRedfinEstimate(url) {
|
|
10
|
+
const resp = await fetch(url, {
|
|
11
|
+
headers: {
|
|
12
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
|
|
13
|
+
"Accept": "text/html",
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
if (!resp.ok)
|
|
17
|
+
return null;
|
|
18
|
+
const html = await resp.text();
|
|
19
|
+
// Prefer Redfin Estimate (current market value) over sold/list price
|
|
20
|
+
const match = html.match(/EstimateValueHeader[^>]*>.*?class="price[^"]*">\$([\d,]+)/) ||
|
|
21
|
+
html.match(/statsValue price[^>]*>[^$]*\$([\d,]+)/) ||
|
|
22
|
+
html.match(/Redfin Estimate[^$]*\$([\d,]+)/) ||
|
|
23
|
+
html.match(/"estimatedValue":\s*([\d.]+)/);
|
|
24
|
+
if (!match)
|
|
25
|
+
return null;
|
|
26
|
+
const value = parseFloat(match[1].replace(/,/g, ""));
|
|
27
|
+
return isNaN(value) ? null : value;
|
|
28
|
+
}
|
|
29
|
+
/** Ensure the manual institution exists */
|
|
30
|
+
function ensureManualInstitution(db) {
|
|
31
|
+
db.prepare(`INSERT INTO institutions (item_id, access_token, name, products) VALUES (?, 'manual', 'Manual Accounts', '[]')
|
|
32
|
+
ON CONFLICT(item_id) DO NOTHING`).run(MANUAL_ITEM_ID);
|
|
33
|
+
}
|
|
34
|
+
/** Add a manual account */
|
|
35
|
+
export function addManualAccount(db, name, type, balance, listingUrl) {
|
|
36
|
+
ensureManualInstitution(db);
|
|
37
|
+
const id = accountId(name);
|
|
38
|
+
const dbType = type === "asset" ? "other" : "loan";
|
|
39
|
+
const subtype = listingUrl ? "property" : (type === "asset" ? "other" : "other");
|
|
40
|
+
db.prepare(`INSERT INTO accounts (account_id, item_id, name, type, subtype, current_balance, updated_at)
|
|
41
|
+
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
|
|
42
|
+
ON CONFLICT(account_id) DO UPDATE SET current_balance = excluded.current_balance, updated_at = datetime('now')`).run(id, MANUAL_ITEM_ID, name, dbType, subtype, balance);
|
|
43
|
+
if (listingUrl) {
|
|
44
|
+
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(LISTING_URL_PREFIX + id, listingUrl);
|
|
45
|
+
}
|
|
46
|
+
return { accountId: id };
|
|
47
|
+
}
|
|
48
|
+
/** Remove a manual account */
|
|
49
|
+
export function removeManualAccount(db, id) {
|
|
50
|
+
db.prepare(`DELETE FROM accounts WHERE account_id = ?`).run(id);
|
|
51
|
+
db.prepare(`DELETE FROM settings WHERE key = ?`).run(LISTING_URL_PREFIX + id);
|
|
52
|
+
}
|
|
53
|
+
/** List all manual accounts */
|
|
54
|
+
export function getManualAccounts(db) {
|
|
55
|
+
const rows = db.prepare(`SELECT account_id, name, type, current_balance FROM accounts WHERE item_id = ?`).all(MANUAL_ITEM_ID);
|
|
56
|
+
return rows.map(r => {
|
|
57
|
+
const urlRow = db.prepare(`SELECT value FROM settings WHERE key = ?`).get(LISTING_URL_PREFIX + r.account_id);
|
|
58
|
+
return { ...r, listing_url: urlRow?.value ?? null };
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
/** Refresh all property values from stored listing URLs (called during daily sync) */
|
|
62
|
+
export async function refreshPropertyValues(db) {
|
|
63
|
+
const urls = db.prepare(`SELECT key, value FROM settings WHERE key LIKE ?`).all(LISTING_URL_PREFIX + "%");
|
|
64
|
+
for (const { key, value: url } of urls) {
|
|
65
|
+
const id = key.slice(LISTING_URL_PREFIX.length);
|
|
66
|
+
try {
|
|
67
|
+
const val = await scrapeRedfinEstimate(url);
|
|
68
|
+
if (val) {
|
|
69
|
+
db.prepare(`UPDATE accounts SET current_balance = ?, updated_at = datetime('now') WHERE account_id = ?`).run(val, id);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Non-fatal
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Check if any listing URLs are configured */
|
|
78
|
+
export function hasListingUrls(db) {
|
|
79
|
+
return !!(db.prepare(`SELECT 1 FROM settings WHERE key LIKE ? LIMIT 1`).get(LISTING_URL_PREFIX + "%"));
|
|
80
|
+
}
|
package/dist/public/link.html
CHANGED
|
@@ -61,24 +61,16 @@
|
|
|
61
61
|
button:disabled { opacity: 0.3; cursor: not-allowed; }
|
|
62
62
|
.success { color: #34d399; font-weight: 500; }
|
|
63
63
|
.error { color: #f87171; font-size: 13px; margin-top: 16px; }
|
|
64
|
-
.
|
|
65
|
-
width:
|
|
66
|
-
height:
|
|
67
|
-
border: 2px solid #34d399;
|
|
68
|
-
border-radius: 50%;
|
|
69
|
-
display: flex;
|
|
70
|
-
align-items: center;
|
|
71
|
-
justify-content: center;
|
|
72
|
-
margin: 0 auto 20px;
|
|
73
|
-
}
|
|
74
|
-
.checkmark svg {
|
|
75
|
-
width: 20px;
|
|
76
|
-
height: 20px;
|
|
64
|
+
.success-title-check {
|
|
65
|
+
width: 18px;
|
|
66
|
+
height: 18px;
|
|
77
67
|
stroke: #34d399;
|
|
78
68
|
fill: none;
|
|
79
69
|
stroke-width: 2.5;
|
|
80
70
|
stroke-linecap: round;
|
|
81
71
|
stroke-linejoin: round;
|
|
72
|
+
vertical-align: -2px;
|
|
73
|
+
margin-right: 6px;
|
|
82
74
|
}
|
|
83
75
|
.institution-logo {
|
|
84
76
|
width: 48px;
|
|
@@ -110,15 +102,14 @@
|
|
|
110
102
|
<div id="error" class="error" style="display:none"></div>
|
|
111
103
|
</div>
|
|
112
104
|
<div id="success-view" style="display:none">
|
|
113
|
-
<div class="checkmark"><svg viewBox="0 0 24 24"><polyline points="4 12 10 18 20 6"/></svg></div>
|
|
114
105
|
<img id="institution-logo" class="institution-logo" style="display:none" alt="">
|
|
115
|
-
<h1 class="success" id="success-title">Account Connected</h1>
|
|
106
|
+
<h1 class="success" id="success-title"><svg class="success-title-check" viewBox="0 0 24 24"><polyline points="4 12 10 18 20 6"/></svg>Account Connected</h1>
|
|
116
107
|
<p>Syncing transactions. Data stays on your machine. You can close this page.</p>
|
|
117
108
|
</div>
|
|
118
109
|
</div>
|
|
119
110
|
<div class="trust">
|
|
120
111
|
<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>
|
|
112
|
+
<span>Secured by Plaid</span>
|
|
122
113
|
</div>
|
|
123
114
|
|
|
124
115
|
<script src="https://cdn.plaid.com/link/v2/stable/link-initialize.js"></script>
|
|
@@ -152,7 +143,8 @@
|
|
|
152
143
|
linkHandler = Plaid.create({
|
|
153
144
|
token: link_token,
|
|
154
145
|
onSuccess: async (publicToken, metadata) => {
|
|
155
|
-
document.getElementById('connect-view').innerHTML = '<div class="spinner"></div><h1>Linking</h1><p>
|
|
146
|
+
document.getElementById('connect-view').innerHTML = '<div class="spinner"></div><h1>Linking</h1><p>Syncing ' + (metadata.institution?.name || 'account') + '...</p>';
|
|
147
|
+
document.getElementById('connect-view').style.display = 'block';
|
|
156
148
|
try {
|
|
157
149
|
const resp = await fetch('/api/exchange', {
|
|
158
150
|
method: 'POST',
|
|
@@ -170,7 +162,8 @@
|
|
|
170
162
|
document.getElementById('success-view').style.display = 'block';
|
|
171
163
|
const instName = result.institution_name || metadata.institution?.name;
|
|
172
164
|
if (instName) {
|
|
173
|
-
document.getElementById('success-title')
|
|
165
|
+
const titleEl = document.getElementById('success-title');
|
|
166
|
+
titleEl.innerHTML = titleEl.querySelector('svg').outerHTML + instName + ' Connected';
|
|
174
167
|
}
|
|
175
168
|
if (result.institution_logo) {
|
|
176
169
|
const logoEl = document.getElementById('institution-logo');
|
package/dist/queries/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
export function getNetWorth(db) {
|
|
2
2
|
const home = db
|
|
3
|
-
.prepare(`SELECT current_balance as value FROM accounts WHERE type = 'other' AND subtype = 'property'
|
|
3
|
+
.prepare(`SELECT COALESCE(SUM(current_balance), 0) as value FROM accounts WHERE type = 'other' AND subtype = 'property'`)
|
|
4
4
|
.get();
|
|
5
5
|
const mortgage = db
|
|
6
|
-
.prepare(`SELECT current_balance as value FROM accounts WHERE type = 'loan' AND subtype = 'mortgage'
|
|
6
|
+
.prepare(`SELECT COALESCE(SUM(current_balance), 0) as value FROM accounts WHERE type = 'loan' AND subtype = 'mortgage'`)
|
|
7
7
|
.get();
|
|
8
8
|
const investments = db
|
|
9
9
|
.prepare(`SELECT COALESCE(SUM(current_balance), 0) as total FROM accounts WHERE type = 'investment'`)
|
|
@@ -25,8 +25,8 @@ export function getNetWorth(db) {
|
|
|
25
25
|
const prev = db
|
|
26
26
|
.prepare(`SELECT net_worth FROM net_worth_history WHERE date <= ? ORDER BY date DESC LIMIT 1`)
|
|
27
27
|
.get(yesterday);
|
|
28
|
-
const homeVal = home
|
|
29
|
-
const mortgageVal = mortgage
|
|
28
|
+
const homeVal = home.value;
|
|
29
|
+
const mortgageVal = mortgage.value;
|
|
30
30
|
return {
|
|
31
31
|
net_worth: assets.total - liabilities.total,
|
|
32
32
|
assets: assets.total,
|
package/dist/server.js
CHANGED
|
@@ -97,6 +97,23 @@ export function startLinkServer() {
|
|
|
97
97
|
// Fetch actual enabled products from Plaid
|
|
98
98
|
const itemResp = await plaidClient.itemGet({ access_token: accessToken });
|
|
99
99
|
const products = (itemResp.data.item.products || []);
|
|
100
|
+
// Remove duplicate institution if re-linking the same one (Plaid gives a new item_id each time)
|
|
101
|
+
const institutionId = req.body.institution_id;
|
|
102
|
+
if (institutionId) {
|
|
103
|
+
const existing = db.prepare(`SELECT item_id FROM institutions WHERE name = ? AND item_id != ?`).all(institution_name, itemId);
|
|
104
|
+
for (const old of existing) {
|
|
105
|
+
const oldAccounts = db.prepare(`SELECT account_id FROM accounts WHERE item_id = ?`).all(old.item_id);
|
|
106
|
+
for (const acct of oldAccounts) {
|
|
107
|
+
db.prepare(`DELETE FROM transactions WHERE account_id = ?`).run(acct.account_id);
|
|
108
|
+
db.prepare(`DELETE FROM holdings WHERE account_id = ?`).run(acct.account_id);
|
|
109
|
+
db.prepare(`DELETE FROM investment_transactions WHERE account_id = ?`).run(acct.account_id);
|
|
110
|
+
db.prepare(`DELETE FROM liabilities WHERE account_id = ?`).run(acct.account_id);
|
|
111
|
+
db.prepare(`DELETE FROM recurring WHERE account_id = ?`).run(acct.account_id);
|
|
112
|
+
}
|
|
113
|
+
db.prepare(`DELETE FROM accounts WHERE item_id = ?`).run(old.item_id);
|
|
114
|
+
db.prepare(`DELETE FROM institutions WHERE item_id = ?`).run(old.item_id);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
100
117
|
db.prepare(`INSERT INTO institutions (item_id, access_token, name, products)
|
|
101
118
|
VALUES (?, ?, ?, ?)
|
|
102
119
|
ON CONFLICT(item_id) DO UPDATE SET access_token = excluded.access_token, products = excluded.products`).run(itemId, encryptedToken, institution_name || "Account", JSON.stringify(products));
|
|
@@ -183,9 +200,16 @@ export function startLinkServer() {
|
|
|
183
200
|
}
|
|
184
201
|
catch { }
|
|
185
202
|
}
|
|
203
|
+
// Check if this institution has a mortgage (prompt user for home value)
|
|
204
|
+
const hasMortgage = !!(db.prepare(`SELECT 1 FROM accounts WHERE item_id = ? AND type = 'loan' AND subtype = 'mortgage' LIMIT 1`).get(itemId));
|
|
205
|
+
const hasPropertyAccount = !!(db.prepare(`SELECT 1 FROM accounts WHERE account_id = 'manual-home' LIMIT 1`).get());
|
|
186
206
|
// Clean up session
|
|
187
207
|
linkSessions.delete(session_id);
|
|
188
|
-
res.json({
|
|
208
|
+
res.json({
|
|
209
|
+
success: true,
|
|
210
|
+
institution_name: institution_name,
|
|
211
|
+
institution_logo: institutionLogo,
|
|
212
|
+
});
|
|
189
213
|
// Signal completion
|
|
190
214
|
resolveComplete();
|
|
191
215
|
}
|