ray-finance 0.4.0 → 0.4.1
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/ai/insights.js +13 -39
- package/dist/apple-import.d.ts +53 -0
- package/dist/apple-import.js +372 -0
- package/dist/cli/chat.js +92 -55
- package/dist/cli/commands.js +9 -24
- package/dist/db/bills.d.ts +22 -0
- package/dist/db/bills.js +134 -0
- package/dist/recategorization.d.ts +15 -0
- package/dist/recategorization.js +46 -0
- package/package.json +1 -1
package/dist/ai/insights.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getNetWorth, getAccountBalances, getDebts, getBudgetStatuses, getGoals, compareSpending, formatMoney, categoryLabel, } from "../queries/index.js";
|
|
3
3
|
import { getLatestScore } from "../scoring/index.js";
|
|
4
|
+
import { getUpcomingBills } from "../db/bills.js";
|
|
4
5
|
const MAX_CHARS = 6000;
|
|
5
6
|
export function computeInsights(db) {
|
|
6
7
|
// Fresh install guard
|
|
@@ -138,35 +139,14 @@ function buildGoals(db) {
|
|
|
138
139
|
}
|
|
139
140
|
function buildUpcoming(db) {
|
|
140
141
|
const parts = [];
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
const todayDay = now.getDate();
|
|
144
|
-
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
145
|
-
const endDay = todayDay + 7;
|
|
146
|
-
let bills = [];
|
|
147
|
-
if (endDay <= daysInMonth) {
|
|
148
|
-
bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, endDay);
|
|
149
|
-
}
|
|
150
|
-
else {
|
|
151
|
-
// Wraparound: rest of this month + start of next
|
|
152
|
-
const thisMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, daysInMonth);
|
|
153
|
-
const nextMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ?`).all(endDay - daysInMonth);
|
|
154
|
-
bills = [...thisMonthBills, ...nextMonthBills];
|
|
155
|
-
}
|
|
156
|
-
// Also handle bills on day 31 in shorter months
|
|
157
|
-
if (daysInMonth < 31) {
|
|
158
|
-
const endOfMonthBills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month > ? AND day_of_month NOT IN (SELECT day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?)`).all(daysInMonth, todayDay + 1, Math.min(endDay, daysInMonth));
|
|
159
|
-
// These bills fall on the last day of the month
|
|
160
|
-
if (daysInMonth >= todayDay + 1 && daysInMonth <= endDay) {
|
|
161
|
-
bills.push(...endOfMonthBills);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
142
|
+
const bills = getUpcomingBills(db, 7);
|
|
143
|
+
const today = startOfUtcDay(new Date());
|
|
164
144
|
if (bills.length > 0) {
|
|
165
145
|
const billStrs = bills.slice(0, 5).map(b => {
|
|
166
|
-
const daysUntil = b.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return `${b.name} (${
|
|
146
|
+
const daysUntil = Math.round((b.date.getTime() - today.getTime()) / 86400000);
|
|
147
|
+
const amt = formatMoney(b.amount);
|
|
148
|
+
const extra = b.note ? ` ${b.note}` : "";
|
|
149
|
+
return `${b.name} (${amt}${extra}) due in ${daysUntil} days`;
|
|
170
150
|
});
|
|
171
151
|
parts.push(`UPCOMING: ${billStrs.join(", ")}`);
|
|
172
152
|
}
|
|
@@ -318,21 +298,12 @@ export function cliBriefing(db) {
|
|
|
318
298
|
}
|
|
319
299
|
}
|
|
320
300
|
// Upcoming bills
|
|
321
|
-
const
|
|
322
|
-
const endDay = todayDay + 7;
|
|
323
|
-
let bills = [];
|
|
324
|
-
if (endDay <= daysInMonth) {
|
|
325
|
-
bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, endDay);
|
|
326
|
-
}
|
|
327
|
-
else {
|
|
328
|
-
const a = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ?`).all(todayDay + 1, daysInMonth);
|
|
329
|
-
const b = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ?`).all(endDay - daysInMonth);
|
|
330
|
-
bills = [...a, ...b];
|
|
331
|
-
}
|
|
301
|
+
const bills = getUpcomingBills(db, 7);
|
|
332
302
|
if (bills.length > 0) {
|
|
333
303
|
lines.push("");
|
|
304
|
+
const today = startOfUtcDay(new Date());
|
|
334
305
|
const billStrs = bills.slice(0, 3).map(b => {
|
|
335
|
-
const daysUntil =
|
|
306
|
+
const daysUntil = Math.round((b.date.getTime() - today.getTime()) / 86400000);
|
|
336
307
|
return chalk.dim(`${b.name} ${fmtMoney(b.amount)}`) + chalk.dim(` in ${daysUntil}d`);
|
|
337
308
|
});
|
|
338
309
|
lines.push(` ${chalk.dim("upcoming")} ${billStrs.join(chalk.dim(" · "))}`);
|
|
@@ -357,6 +328,9 @@ export function cliBriefing(db) {
|
|
|
357
328
|
function fmtMoney(n) {
|
|
358
329
|
return "$" + Math.abs(n).toLocaleString("en-US", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
|
359
330
|
}
|
|
331
|
+
function startOfUtcDay(d) {
|
|
332
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
333
|
+
}
|
|
360
334
|
function miniBar(pct) {
|
|
361
335
|
const width = 8;
|
|
362
336
|
const clamped = Math.max(0, Math.min(100, pct));
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type BetterSqlite3 from "libsql";
|
|
2
|
+
type Database = BetterSqlite3.Database;
|
|
3
|
+
export interface AppleImportOptions {
|
|
4
|
+
csvPath: string;
|
|
5
|
+
balance?: number;
|
|
6
|
+
limit?: number;
|
|
7
|
+
replaceRange?: boolean;
|
|
8
|
+
dryRun?: boolean;
|
|
9
|
+
}
|
|
10
|
+
export interface AppleImportResult {
|
|
11
|
+
accountId: string;
|
|
12
|
+
accountCreated: boolean;
|
|
13
|
+
rowsParsed: number;
|
|
14
|
+
rowsInserted: number;
|
|
15
|
+
rowsSkipped: number;
|
|
16
|
+
rowsDeleted: number;
|
|
17
|
+
warnings: string[];
|
|
18
|
+
dateRange: {
|
|
19
|
+
first: string;
|
|
20
|
+
last: string;
|
|
21
|
+
} | null;
|
|
22
|
+
balance: number | null;
|
|
23
|
+
}
|
|
24
|
+
interface ParsedRow {
|
|
25
|
+
transactionDate: string;
|
|
26
|
+
description: string;
|
|
27
|
+
merchant: string;
|
|
28
|
+
category: string;
|
|
29
|
+
type: string;
|
|
30
|
+
amount: number;
|
|
31
|
+
}
|
|
32
|
+
/** Parse RFC 4180 CSV: handles quoted fields, embedded commas, and escaped quotes ("") */
|
|
33
|
+
export declare function parseCsv(text: string): string[][];
|
|
34
|
+
/** Parse CSV text. Returns parsed rows + warnings for malformed rows. Throws on bad header. */
|
|
35
|
+
export declare function parseAppleCsv(text: string): {
|
|
36
|
+
rows: ParsedRow[];
|
|
37
|
+
warnings: string[];
|
|
38
|
+
replaceWindow: {
|
|
39
|
+
first: string;
|
|
40
|
+
last: string;
|
|
41
|
+
} | null;
|
|
42
|
+
};
|
|
43
|
+
/** True if the Apple Card account row already exists in the DB */
|
|
44
|
+
export declare function appleAccountExists(db: Database): boolean;
|
|
45
|
+
/** Returns the current balance of the Apple Card account, or null if it doesn't exist */
|
|
46
|
+
export declare function getAppleAccountBalance(db: Database): number | null;
|
|
47
|
+
/** Returns the current credit limit of the Apple Card account, or null if unset */
|
|
48
|
+
export declare function getAppleAccountLimit(db: Database): number | null;
|
|
49
|
+
/** Count of existing Apple Card rows within a date range (for --replace-range preview) */
|
|
50
|
+
export declare function countAppleRowsInRange(db: Database, first: string, last: string): number;
|
|
51
|
+
/** Run the import end-to-end. Returns a result struct for the CLI layer to format. */
|
|
52
|
+
export declare function runAppleImport(db: Database, opts: AppleImportOptions): AppleImportResult;
|
|
53
|
+
export {};
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
import { createHash } from "crypto";
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
const INSTITUTION_ID = "manual-apple";
|
|
4
|
+
const INSTITUTION_NAME = "Apple";
|
|
5
|
+
const ACCOUNT_ID = "manual-apple-card";
|
|
6
|
+
const ACCOUNT_NAME = "Apple Card";
|
|
7
|
+
const EXPECTED_HEADER = [
|
|
8
|
+
"Transaction Date",
|
|
9
|
+
"Clearing Date",
|
|
10
|
+
"Description",
|
|
11
|
+
"Merchant",
|
|
12
|
+
"Category",
|
|
13
|
+
"Type",
|
|
14
|
+
"Amount (USD)",
|
|
15
|
+
"Purchased By",
|
|
16
|
+
];
|
|
17
|
+
// Apple exports Title Case categories; Ray's scoring and spending queries expect
|
|
18
|
+
// Plaid UPPERCASE_SNAKE_CASE codes. Mapping here so Apple transactions participate
|
|
19
|
+
// in restaurant/shopping counts, budget checks, and spending totals.
|
|
20
|
+
const CATEGORY_MAP = {
|
|
21
|
+
Restaurants: { category: "FOOD_AND_DRINK", subcategory: "FOOD_AND_DRINK_RESTAURANT" },
|
|
22
|
+
Grocery: { category: "FOOD_AND_DRINK", subcategory: "FOOD_AND_DRINK_GROCERIES" },
|
|
23
|
+
Alcohol: { category: "FOOD_AND_DRINK", subcategory: "FOOD_AND_DRINK_ALCOHOL_AND_BARS" },
|
|
24
|
+
Shopping: { category: "GENERAL_MERCHANDISE", subcategory: null },
|
|
25
|
+
Gas: { category: "TRANSPORTATION", subcategory: "TRANSPORTATION_GAS" },
|
|
26
|
+
Transportation: { category: "TRANSPORTATION", subcategory: null },
|
|
27
|
+
Tolls: { category: "TRANSPORTATION", subcategory: "TRANSPORTATION_TOLLS" },
|
|
28
|
+
Airlines: { category: "TRAVEL", subcategory: "TRAVEL_FLIGHTS" },
|
|
29
|
+
Hotels: { category: "TRAVEL", subcategory: "TRAVEL_LODGING" },
|
|
30
|
+
Entertainment: { category: "ENTERTAINMENT", subcategory: null },
|
|
31
|
+
Medical: { category: "MEDICAL", subcategory: null },
|
|
32
|
+
Utilities: { category: "RENT_AND_UTILITIES", subcategory: null },
|
|
33
|
+
"Govt-services-parking": { category: "GOVERNMENT_AND_NON_PROFIT", subcategory: null },
|
|
34
|
+
// Payment (negative): card payment from your bank. Mapped to TRANSFER_IN
|
|
35
|
+
// because Ray's income queries (getIncome, getCashFlow*, compareSpending)
|
|
36
|
+
// exclude only TRANSFER_IN from `amount < 0`-as-income — LOAN_PAYMENTS would
|
|
37
|
+
// be counted as income and inflate cash-flow numbers. The corresponding
|
|
38
|
+
// outflow on the bank account will appear as TRANSFER_OUT via Plaid.
|
|
39
|
+
Payment: { category: "TRANSFER_IN", subcategory: null },
|
|
40
|
+
// Installment (positive): Apple Card monthly financing charge (e.g., iPhone).
|
|
41
|
+
// Amortization of a prior purchase, not new spending — LOAN_PAYMENTS keeps it
|
|
42
|
+
// out of total-spend aggregation.
|
|
43
|
+
Installment: { category: "LOAN_PAYMENTS", subcategory: null },
|
|
44
|
+
// Credit (negative): a refund. Without mapping, queries/index.ts would count
|
|
45
|
+
// it as income. TRANSFER_IN excludes it from income totals.
|
|
46
|
+
Credit: { category: "TRANSFER_IN", subcategory: null },
|
|
47
|
+
Other: { category: null, subcategory: null },
|
|
48
|
+
};
|
|
49
|
+
const TYPE_LABELS = {
|
|
50
|
+
Payment: "transfer",
|
|
51
|
+
Credit: "refund",
|
|
52
|
+
Installment: "installment",
|
|
53
|
+
};
|
|
54
|
+
/** Parse RFC 4180 CSV: handles quoted fields, embedded commas, and escaped quotes ("") */
|
|
55
|
+
export function parseCsv(text) {
|
|
56
|
+
const rows = [];
|
|
57
|
+
let row = [];
|
|
58
|
+
let field = "";
|
|
59
|
+
let inQuotes = false;
|
|
60
|
+
let i = 0;
|
|
61
|
+
// Strip BOM if present
|
|
62
|
+
if (text.charCodeAt(0) === 0xfeff)
|
|
63
|
+
i = 1;
|
|
64
|
+
while (i < text.length) {
|
|
65
|
+
const c = text[i];
|
|
66
|
+
if (inQuotes) {
|
|
67
|
+
if (c === '"') {
|
|
68
|
+
if (text[i + 1] === '"') {
|
|
69
|
+
field += '"';
|
|
70
|
+
i += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
inQuotes = false;
|
|
74
|
+
i++;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
field += c;
|
|
78
|
+
i++;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (c === '"') {
|
|
82
|
+
inQuotes = true;
|
|
83
|
+
i++;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (c === ",") {
|
|
87
|
+
row.push(field);
|
|
88
|
+
field = "";
|
|
89
|
+
i++;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (c === "\r") {
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (c === "\n") {
|
|
97
|
+
row.push(field);
|
|
98
|
+
rows.push(row);
|
|
99
|
+
row = [];
|
|
100
|
+
field = "";
|
|
101
|
+
i++;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
field += c;
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
// Final field / row (no trailing newline)
|
|
108
|
+
if (field.length > 0 || row.length > 0) {
|
|
109
|
+
row.push(field);
|
|
110
|
+
rows.push(row);
|
|
111
|
+
}
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
function parseDate(mdy) {
|
|
115
|
+
const m = mdy.match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
|
|
116
|
+
if (!m)
|
|
117
|
+
return null;
|
|
118
|
+
return `${m[3]}-${m[1]}-${m[2]}`;
|
|
119
|
+
}
|
|
120
|
+
function parseAmount(s) {
|
|
121
|
+
const n = parseFloat(s);
|
|
122
|
+
return isNaN(n) ? null : n;
|
|
123
|
+
}
|
|
124
|
+
function transactionId(date, amount, merchant, occurrence) {
|
|
125
|
+
const key = `${date}|${amount}|${merchant}|${occurrence}`;
|
|
126
|
+
return "apple-" + createHash("sha256").update(key).digest("hex").slice(0, 16);
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Assigns a stable occurrence index to rows sharing the same (date, amount, merchant).
|
|
130
|
+
* Without this, genuinely separate same-day same-merchant same-amount transactions
|
|
131
|
+
* (e.g. two $2.40 subway swipes) would collapse into one via hash collision.
|
|
132
|
+
*
|
|
133
|
+
* Stability requirement: the same CSV exported twice must produce the same indices
|
|
134
|
+
* so re-imports stay idempotent. We sort rows by every field before numbering so
|
|
135
|
+
* the order doesn't depend on Apple's export order.
|
|
136
|
+
*/
|
|
137
|
+
function assignOccurrenceIndices(rows) {
|
|
138
|
+
const sorted = [...rows].sort((a, b) => {
|
|
139
|
+
if (a.transactionDate !== b.transactionDate)
|
|
140
|
+
return a.transactionDate < b.transactionDate ? -1 : 1;
|
|
141
|
+
if (a.amount !== b.amount)
|
|
142
|
+
return a.amount - b.amount;
|
|
143
|
+
if (a.merchant !== b.merchant)
|
|
144
|
+
return a.merchant < b.merchant ? -1 : 1;
|
|
145
|
+
if (a.description !== b.description)
|
|
146
|
+
return a.description < b.description ? -1 : 1;
|
|
147
|
+
if (a.category !== b.category)
|
|
148
|
+
return a.category < b.category ? -1 : 1;
|
|
149
|
+
if (a.type !== b.type)
|
|
150
|
+
return a.type < b.type ? -1 : 1;
|
|
151
|
+
return 0;
|
|
152
|
+
});
|
|
153
|
+
const counts = {};
|
|
154
|
+
return sorted.map(r => {
|
|
155
|
+
const key = `${r.transactionDate}|${r.amount}|${r.merchant}`;
|
|
156
|
+
const occurrence = counts[key] ?? 0;
|
|
157
|
+
counts[key] = occurrence + 1;
|
|
158
|
+
return { ...r, occurrence };
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
function mapCategory(appleCat) {
|
|
162
|
+
return CATEGORY_MAP[appleCat] ?? { category: null, subcategory: null };
|
|
163
|
+
}
|
|
164
|
+
/** Parse CSV text. Returns parsed rows + warnings for malformed rows. Throws on bad header. */
|
|
165
|
+
export function parseAppleCsv(text) {
|
|
166
|
+
const raw = parseCsv(text);
|
|
167
|
+
if (raw.length === 0)
|
|
168
|
+
throw new Error("CSV file is empty.");
|
|
169
|
+
const header = raw[0];
|
|
170
|
+
const headerMismatch = header.length !== EXPECTED_HEADER.length ||
|
|
171
|
+
EXPECTED_HEADER.some((col, i) => header[i] !== col);
|
|
172
|
+
if (headerMismatch) {
|
|
173
|
+
throw new Error(`This doesn't look like an Apple Card CSV export.\n` +
|
|
174
|
+
` Expected columns: ${EXPECTED_HEADER.join(", ")}\n` +
|
|
175
|
+
` Got: ${header.join(", ")}\n` +
|
|
176
|
+
` Export from https://card.apple.com/ (the web portal, not the Wallet app).`);
|
|
177
|
+
}
|
|
178
|
+
const rows = [];
|
|
179
|
+
const warnings = [];
|
|
180
|
+
// Every row whose Transaction Date parsed, including rows later skipped for
|
|
181
|
+
// bad amount/columns. `--replace-range` must delete across the *full* CSV
|
|
182
|
+
// window — narrowing to surviving rows would leave stale rows at the edges
|
|
183
|
+
// when a boundary row has a bad amount.
|
|
184
|
+
const allParsedDates = [];
|
|
185
|
+
for (let i = 1; i < raw.length; i++) {
|
|
186
|
+
const r = raw[i];
|
|
187
|
+
if (r.length === 1 && r[0] === "")
|
|
188
|
+
continue;
|
|
189
|
+
// Parse date first — recorded in allParsedDates regardless of later
|
|
190
|
+
// column/amount failures, so --replace-range covers the full CSV window
|
|
191
|
+
// even when a boundary row has too few columns.
|
|
192
|
+
const date = r.length >= 1 ? parseDate(r[0]) : null;
|
|
193
|
+
if (date)
|
|
194
|
+
allParsedDates.push(date);
|
|
195
|
+
if (r.length < EXPECTED_HEADER.length) {
|
|
196
|
+
warnings.push(`Row ${i + 1}: expected ${EXPECTED_HEADER.length} columns, got ${r.length} — skipped`);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (!date) {
|
|
200
|
+
warnings.push(`Row ${i + 1}: unparseable Transaction Date "${r[0]}" — skipped`);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const amount = parseAmount(r[6]);
|
|
204
|
+
if (amount === null) {
|
|
205
|
+
warnings.push(`Row ${i + 1}: unparseable Amount "${r[6]}" — skipped`);
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
rows.push({
|
|
209
|
+
transactionDate: date,
|
|
210
|
+
description: r[2],
|
|
211
|
+
merchant: r[3],
|
|
212
|
+
category: r[4],
|
|
213
|
+
type: r[5],
|
|
214
|
+
amount,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
let replaceWindow = null;
|
|
218
|
+
if (allParsedDates.length > 0) {
|
|
219
|
+
const sorted = [...allParsedDates].sort();
|
|
220
|
+
replaceWindow = { first: sorted[0], last: sorted[sorted.length - 1] };
|
|
221
|
+
}
|
|
222
|
+
return { rows, warnings, replaceWindow };
|
|
223
|
+
}
|
|
224
|
+
/** True if the Apple Card account row already exists in the DB */
|
|
225
|
+
export function appleAccountExists(db) {
|
|
226
|
+
return Boolean(db.prepare(`SELECT 1 FROM accounts WHERE account_id = ?`).get(ACCOUNT_ID));
|
|
227
|
+
}
|
|
228
|
+
/** Returns the current balance of the Apple Card account, or null if it doesn't exist */
|
|
229
|
+
export function getAppleAccountBalance(db) {
|
|
230
|
+
const r = db.prepare(`SELECT current_balance FROM accounts WHERE account_id = ?`).get(ACCOUNT_ID);
|
|
231
|
+
return r?.current_balance ?? null;
|
|
232
|
+
}
|
|
233
|
+
/** Returns the current credit limit of the Apple Card account, or null if unset */
|
|
234
|
+
export function getAppleAccountLimit(db) {
|
|
235
|
+
const r = db.prepare(`SELECT balance_limit FROM accounts WHERE account_id = ?`).get(ACCOUNT_ID);
|
|
236
|
+
return r?.balance_limit ?? null;
|
|
237
|
+
}
|
|
238
|
+
/** Count of existing Apple Card rows within a date range (for --replace-range preview) */
|
|
239
|
+
export function countAppleRowsInRange(db, first, last) {
|
|
240
|
+
const r = db
|
|
241
|
+
.prepare(`SELECT COUNT(*) as n FROM transactions WHERE account_id = ? AND date BETWEEN ? AND ?`)
|
|
242
|
+
.get(ACCOUNT_ID, first, last);
|
|
243
|
+
return r.n;
|
|
244
|
+
}
|
|
245
|
+
/** Run the import end-to-end. Returns a result struct for the CLI layer to format. */
|
|
246
|
+
export function runAppleImport(db, opts) {
|
|
247
|
+
const text = readFileSync(opts.csvPath, "utf-8");
|
|
248
|
+
const { rows, warnings, replaceWindow } = parseAppleCsv(text);
|
|
249
|
+
if (rows.length === 0) {
|
|
250
|
+
return {
|
|
251
|
+
accountId: ACCOUNT_ID,
|
|
252
|
+
accountCreated: false,
|
|
253
|
+
rowsParsed: 0,
|
|
254
|
+
rowsInserted: 0,
|
|
255
|
+
rowsSkipped: 0,
|
|
256
|
+
rowsDeleted: 0,
|
|
257
|
+
warnings,
|
|
258
|
+
dateRange: null,
|
|
259
|
+
balance: null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
const dates = rows.map(r => r.transactionDate).sort();
|
|
263
|
+
const first = dates[0];
|
|
264
|
+
const last = dates[dates.length - 1];
|
|
265
|
+
const replaceFirst = replaceWindow?.first ?? first;
|
|
266
|
+
const replaceLast = replaceWindow?.last ?? last;
|
|
267
|
+
if (opts.dryRun) {
|
|
268
|
+
const rowsDeletedPreview = opts.replaceRange ? countAppleRowsInRange(db, replaceFirst, replaceLast) : 0;
|
|
269
|
+
// With --replace-range, every CSV row is a fresh insert (the prior rows are
|
|
270
|
+
// deleted first). Without it, count which transaction_ids already exist so
|
|
271
|
+
// the user sees a real would-insert vs would-skip breakdown — the whole
|
|
272
|
+
// point of --dry-run.
|
|
273
|
+
let wouldInsert = rows.length;
|
|
274
|
+
let wouldSkip = 0;
|
|
275
|
+
if (!opts.replaceRange) {
|
|
276
|
+
const exists = db.prepare(`SELECT 1 FROM transactions WHERE transaction_id = ?`);
|
|
277
|
+
const indexed = assignOccurrenceIndices(rows);
|
|
278
|
+
wouldInsert = 0;
|
|
279
|
+
for (const row of indexed) {
|
|
280
|
+
const id = transactionId(row.transactionDate, row.amount, row.merchant, row.occurrence);
|
|
281
|
+
if (exists.get(id))
|
|
282
|
+
wouldSkip++;
|
|
283
|
+
else
|
|
284
|
+
wouldInsert++;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
accountId: ACCOUNT_ID,
|
|
289
|
+
accountCreated: !appleAccountExists(db),
|
|
290
|
+
rowsParsed: rows.length,
|
|
291
|
+
rowsInserted: wouldInsert,
|
|
292
|
+
rowsSkipped: wouldSkip,
|
|
293
|
+
rowsDeleted: rowsDeletedPreview,
|
|
294
|
+
warnings,
|
|
295
|
+
dateRange: { first, last },
|
|
296
|
+
balance: opts.balance ?? getAppleAccountBalance(db),
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
const accountCreated = !appleAccountExists(db);
|
|
300
|
+
let rowsDeleted = 0;
|
|
301
|
+
let rowsInserted = 0;
|
|
302
|
+
let rowsSkipped = 0;
|
|
303
|
+
const insertInst = db.prepare(`INSERT INTO institutions (item_id, access_token, name, products)
|
|
304
|
+
VALUES (?, 'manual', ?, '[]')
|
|
305
|
+
ON CONFLICT(item_id) DO NOTHING`);
|
|
306
|
+
const insertAcc = db.prepare(`INSERT INTO accounts (account_id, item_id, name, type, subtype, currency, current_balance, balance_limit, updated_at)
|
|
307
|
+
VALUES (?, ?, ?, 'credit', 'credit card', 'USD', ?, ?, datetime('now'))
|
|
308
|
+
ON CONFLICT(account_id) DO UPDATE SET
|
|
309
|
+
current_balance = COALESCE(excluded.current_balance, current_balance),
|
|
310
|
+
balance_limit = COALESCE(excluded.balance_limit, balance_limit),
|
|
311
|
+
updated_at = datetime('now')`);
|
|
312
|
+
// Derive available_balance = limit - current_balance after the upsert resolves
|
|
313
|
+
// both fields (so re-runs that omit one of --balance/--limit still produce a
|
|
314
|
+
// correct value using the prior stored side). Without this, ai/insights.ts
|
|
315
|
+
// skips the card from utilization (it requires available_balance IS NOT NULL).
|
|
316
|
+
const updateAvailable = db.prepare(`UPDATE accounts
|
|
317
|
+
SET available_balance = balance_limit - current_balance
|
|
318
|
+
WHERE account_id = ?
|
|
319
|
+
AND balance_limit IS NOT NULL
|
|
320
|
+
AND current_balance IS NOT NULL`);
|
|
321
|
+
// Mirror the resolved balance into the liabilities table. getDebts() uses
|
|
322
|
+
// liabilities as the authoritative source (with rate/min payment/due date)
|
|
323
|
+
// for any account_id it covers, and falls through to accounts only for
|
|
324
|
+
// account_ids not in liabilities. Without this upsert, Apple Card debt would
|
|
325
|
+
// appear only in the fallback path without rate/min-payment metadata.
|
|
326
|
+
// type='credit' matches Plaid's syncLiabilities convention (src/plaid/sync.ts)
|
|
327
|
+
// — keeps debt-view labels consistent across import sources.
|
|
328
|
+
const upsertLiability = db.prepare(`INSERT INTO liabilities (account_id, type, current_balance, updated_at)
|
|
329
|
+
SELECT ?, 'credit', current_balance, datetime('now')
|
|
330
|
+
FROM accounts WHERE account_id = ? AND current_balance IS NOT NULL
|
|
331
|
+
ON CONFLICT(account_id, type) DO UPDATE SET
|
|
332
|
+
current_balance = excluded.current_balance,
|
|
333
|
+
updated_at = datetime('now')`);
|
|
334
|
+
const deleteRange = db.prepare(`DELETE FROM transactions WHERE account_id = ? AND date BETWEEN ? AND ?`);
|
|
335
|
+
const insertTx = db.prepare(`INSERT OR IGNORE INTO transactions
|
|
336
|
+
(transaction_id, account_id, amount, date, name, merchant_name, category, subcategory,
|
|
337
|
+
pending, iso_currency_code, payment_channel, label, note)
|
|
338
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, 'USD', NULL, ?, NULL)`);
|
|
339
|
+
const work = db.transaction(() => {
|
|
340
|
+
insertInst.run(INSTITUTION_ID, INSTITUTION_NAME);
|
|
341
|
+
insertAcc.run(ACCOUNT_ID, INSTITUTION_ID, ACCOUNT_NAME, opts.balance ?? null, opts.limit ?? null);
|
|
342
|
+
updateAvailable.run(ACCOUNT_ID);
|
|
343
|
+
upsertLiability.run(ACCOUNT_ID, ACCOUNT_ID);
|
|
344
|
+
if (opts.replaceRange) {
|
|
345
|
+
const info = deleteRange.run(ACCOUNT_ID, replaceFirst, replaceLast);
|
|
346
|
+
rowsDeleted = Number(info.changes);
|
|
347
|
+
}
|
|
348
|
+
const indexed = assignOccurrenceIndices(rows);
|
|
349
|
+
for (const row of indexed) {
|
|
350
|
+
const mapping = mapCategory(row.category);
|
|
351
|
+
const id = transactionId(row.transactionDate, row.amount, row.merchant, row.occurrence);
|
|
352
|
+
const label = TYPE_LABELS[row.type] ?? null;
|
|
353
|
+
const info = insertTx.run(id, ACCOUNT_ID, row.amount, row.transactionDate, row.description || row.merchant, row.merchant || null, mapping.category, mapping.subcategory, label);
|
|
354
|
+
if (Number(info.changes) === 1)
|
|
355
|
+
rowsInserted++;
|
|
356
|
+
else
|
|
357
|
+
rowsSkipped++;
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
work();
|
|
361
|
+
return {
|
|
362
|
+
accountId: ACCOUNT_ID,
|
|
363
|
+
accountCreated,
|
|
364
|
+
rowsParsed: rows.length,
|
|
365
|
+
rowsInserted,
|
|
366
|
+
rowsSkipped,
|
|
367
|
+
rowsDeleted,
|
|
368
|
+
warnings,
|
|
369
|
+
dateRange: { first, last },
|
|
370
|
+
balance: opts.balance ?? getAppleAccountBalance(db),
|
|
371
|
+
};
|
|
372
|
+
}
|
package/dist/cli/chat.js
CHANGED
|
@@ -8,13 +8,51 @@ function rawReadLine(prompt, belowLines) {
|
|
|
8
8
|
let cursor = 0; // cursor position within buf
|
|
9
9
|
const out = process.stdout;
|
|
10
10
|
const promptLen = stripAnsi(prompt).length;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
11
|
+
const cols = () => process.stdout.columns || 80;
|
|
12
|
+
// Rows occupied by prompt+text of the given length (accounting for wrap)
|
|
13
|
+
const calcRows = (len) => {
|
|
14
|
+
const total = promptLen + len;
|
|
15
|
+
return Math.max(1, Math.ceil(Math.max(1, total) / cols()));
|
|
16
|
+
};
|
|
17
|
+
// (row, col) of the position at offset `len` from the start of the prompt
|
|
18
|
+
const calcPos = (len) => {
|
|
19
|
+
const w = cols();
|
|
20
|
+
const abs = promptLen + len;
|
|
21
|
+
return { row: Math.floor(abs / w), col: abs % w };
|
|
22
|
+
};
|
|
23
|
+
let renderedRows = 1; // rows the prompt+buf occupy on screen
|
|
24
|
+
let curRow = 0; // current cursor row offset within the prompt area
|
|
25
|
+
// Full re-render: assumes cursor is at (curRow, *) within the prompt area. Moves
|
|
26
|
+
// to the top of the prompt, clears to end of screen, rewrites prompt+buf and
|
|
27
|
+
// belowLines, then positions cursor at `cursor` within buf.
|
|
28
|
+
const render = () => {
|
|
29
|
+
if (curRow > 0)
|
|
30
|
+
out.write(`\x1b[${curRow}A`);
|
|
31
|
+
out.write("\r");
|
|
32
|
+
// Clear from here to end of screen — drops all old wrapped rows AND belowLines
|
|
33
|
+
out.write("\x1b[J");
|
|
34
|
+
out.write(prompt + buf);
|
|
35
|
+
const endPos = calcPos(buf.length);
|
|
36
|
+
let fromRow = endPos.row;
|
|
37
|
+
let fromCol = endPos.col;
|
|
38
|
+
if (belowLines.length > 0) {
|
|
39
|
+
out.write("\n" + belowLines.join("\n"));
|
|
40
|
+
out.write(`\x1b[${belowLines.length}A`);
|
|
41
|
+
out.write("\r");
|
|
42
|
+
fromCol = 0;
|
|
43
|
+
}
|
|
44
|
+
const tgt = calcPos(cursor);
|
|
45
|
+
if (fromRow > tgt.row)
|
|
46
|
+
out.write(`\x1b[${fromRow - tgt.row}A`);
|
|
47
|
+
else if (tgt.row > fromRow)
|
|
48
|
+
out.write(`\x1b[${tgt.row - fromRow}B`);
|
|
49
|
+
if (tgt.col !== fromCol) {
|
|
50
|
+
out.write("\r");
|
|
51
|
+
if (tgt.col > 0)
|
|
52
|
+
out.write(`\x1b[${tgt.col}C`);
|
|
53
|
+
}
|
|
54
|
+
renderedRows = calcRows(buf.length);
|
|
55
|
+
curRow = tgt.row;
|
|
18
56
|
};
|
|
19
57
|
// Find the start of the previous word boundary
|
|
20
58
|
const wordLeft = () => {
|
|
@@ -34,14 +72,8 @@ function rawReadLine(prompt, belowLines) {
|
|
|
34
72
|
p++; // skip trailing spaces
|
|
35
73
|
return p;
|
|
36
74
|
};
|
|
37
|
-
//
|
|
38
|
-
|
|
39
|
-
if (belowLines.length > 0) {
|
|
40
|
-
out.write("\n" + belowLines.join("\n"));
|
|
41
|
-
// Move cursor back up to the prompt line and to the end of prompt text
|
|
42
|
-
out.write(`\x1b[${belowLines.length}A`);
|
|
43
|
-
out.write("\r" + prompt);
|
|
44
|
-
}
|
|
75
|
+
// Initial render
|
|
76
|
+
render();
|
|
45
77
|
process.stdin.setRawMode(true);
|
|
46
78
|
process.stdin.resume();
|
|
47
79
|
process.stdin.setEncoding("utf8");
|
|
@@ -57,36 +89,40 @@ function rawReadLine(prompt, belowLines) {
|
|
|
57
89
|
if (code === 3 || code === 4) {
|
|
58
90
|
cleanup();
|
|
59
91
|
out.write("\n");
|
|
60
|
-
resolve("\x03");
|
|
92
|
+
resolve({ input: "\x03", rows: renderedRows });
|
|
61
93
|
return;
|
|
62
94
|
}
|
|
63
95
|
// Ctrl+A — beginning of line
|
|
64
96
|
if (code === 1) {
|
|
65
97
|
if (cursor > 0) {
|
|
66
|
-
out.write(`\x1b[${cursor}D`);
|
|
67
98
|
cursor = 0;
|
|
99
|
+
render();
|
|
68
100
|
}
|
|
69
101
|
continue;
|
|
70
102
|
}
|
|
71
103
|
// Ctrl+E — end of line
|
|
72
104
|
if (code === 5) {
|
|
73
105
|
if (cursor < buf.length) {
|
|
74
|
-
out.write(`\x1b[${buf.length - cursor}C`);
|
|
75
106
|
cursor = buf.length;
|
|
107
|
+
render();
|
|
76
108
|
}
|
|
77
109
|
continue;
|
|
78
110
|
}
|
|
79
111
|
// Ctrl+K — delete from cursor to end of line
|
|
80
112
|
if (code === 11) {
|
|
81
|
-
|
|
82
|
-
|
|
113
|
+
if (cursor < buf.length) {
|
|
114
|
+
buf = buf.slice(0, cursor);
|
|
115
|
+
render();
|
|
116
|
+
}
|
|
83
117
|
continue;
|
|
84
118
|
}
|
|
85
119
|
// Ctrl+U — delete from cursor to beginning of line
|
|
86
120
|
if (code === 21) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
121
|
+
if (cursor > 0) {
|
|
122
|
+
buf = buf.slice(cursor);
|
|
123
|
+
cursor = 0;
|
|
124
|
+
render();
|
|
125
|
+
}
|
|
90
126
|
continue;
|
|
91
127
|
}
|
|
92
128
|
// Ctrl+W — delete word backward
|
|
@@ -95,21 +131,27 @@ function rawReadLine(prompt, belowLines) {
|
|
|
95
131
|
const target = wordLeft();
|
|
96
132
|
buf = buf.slice(0, target) + buf.slice(cursor);
|
|
97
133
|
cursor = target;
|
|
98
|
-
|
|
134
|
+
render();
|
|
99
135
|
}
|
|
100
136
|
continue;
|
|
101
137
|
}
|
|
102
138
|
// Enter
|
|
103
139
|
if (code === 13) {
|
|
104
140
|
cleanup();
|
|
105
|
-
// Move cursor to end of buf
|
|
106
|
-
|
|
107
|
-
|
|
141
|
+
// Move cursor to end of buf
|
|
142
|
+
const end = calcPos(buf.length);
|
|
143
|
+
if (end.row > curRow)
|
|
144
|
+
out.write(`\x1b[${end.row - curRow}B`);
|
|
145
|
+
else if (curRow > end.row)
|
|
146
|
+
out.write(`\x1b[${curRow - end.row}A`);
|
|
147
|
+
out.write("\r");
|
|
148
|
+
if (end.col > 0)
|
|
149
|
+
out.write(`\x1b[${end.col}C`);
|
|
108
150
|
// Move past the below-content lines, then newline
|
|
109
151
|
for (let j = 0; j < belowLines.length; j++)
|
|
110
152
|
out.write("\x1b[1B");
|
|
111
153
|
out.write("\n");
|
|
112
|
-
resolve(buf);
|
|
154
|
+
resolve({ input: buf, rows: renderedRows });
|
|
113
155
|
return;
|
|
114
156
|
}
|
|
115
157
|
// Backspace
|
|
@@ -117,7 +159,7 @@ function rawReadLine(prompt, belowLines) {
|
|
|
117
159
|
if (cursor > 0) {
|
|
118
160
|
buf = buf.slice(0, cursor - 1) + buf.slice(cursor);
|
|
119
161
|
cursor--;
|
|
120
|
-
|
|
162
|
+
render();
|
|
121
163
|
}
|
|
122
164
|
continue;
|
|
123
165
|
}
|
|
@@ -130,7 +172,7 @@ function rawReadLine(prompt, belowLines) {
|
|
|
130
172
|
const target = wordLeft();
|
|
131
173
|
buf = buf.slice(0, target) + buf.slice(cursor);
|
|
132
174
|
cursor = target;
|
|
133
|
-
|
|
175
|
+
render();
|
|
134
176
|
}
|
|
135
177
|
continue;
|
|
136
178
|
}
|
|
@@ -139,8 +181,8 @@ function rawReadLine(prompt, belowLines) {
|
|
|
139
181
|
i++;
|
|
140
182
|
const target = wordLeft();
|
|
141
183
|
if (target < cursor) {
|
|
142
|
-
out.write(`\x1b[${cursor - target}D`);
|
|
143
184
|
cursor = target;
|
|
185
|
+
render();
|
|
144
186
|
}
|
|
145
187
|
continue;
|
|
146
188
|
}
|
|
@@ -148,8 +190,8 @@ function rawReadLine(prompt, belowLines) {
|
|
|
148
190
|
i++;
|
|
149
191
|
const target = wordRight();
|
|
150
192
|
if (target > cursor) {
|
|
151
|
-
out.write(`\x1b[${target - cursor}C`);
|
|
152
193
|
cursor = target;
|
|
194
|
+
render();
|
|
153
195
|
}
|
|
154
196
|
continue;
|
|
155
197
|
}
|
|
@@ -165,47 +207,42 @@ function rawReadLine(prompt, belowLines) {
|
|
|
165
207
|
const final = chunk[i];
|
|
166
208
|
// Modifier keys: ;3 = Option, ;5 = Ctrl, ;9 = Cmd (Kitty protocol)
|
|
167
209
|
const isWordMod = seq === "1;3" || seq === "1;5" || seq === "1;9";
|
|
168
|
-
const isCmd = seq === "1;9";
|
|
169
210
|
if (final === "D") {
|
|
170
211
|
if (isWordMod) {
|
|
171
|
-
// Option/Ctrl/Cmd+Left — word backward
|
|
172
212
|
const target = wordLeft();
|
|
173
213
|
if (target < cursor) {
|
|
174
|
-
out.write(`\x1b[${cursor - target}D`);
|
|
175
214
|
cursor = target;
|
|
215
|
+
render();
|
|
176
216
|
}
|
|
177
217
|
}
|
|
178
218
|
else if (cursor > 0) {
|
|
179
219
|
cursor--;
|
|
180
|
-
|
|
220
|
+
render();
|
|
181
221
|
}
|
|
182
222
|
}
|
|
183
223
|
else if (final === "C") {
|
|
184
224
|
if (isWordMod) {
|
|
185
|
-
// Option/Ctrl/Cmd+Right — word forward
|
|
186
225
|
const target = wordRight();
|
|
187
226
|
if (target > cursor) {
|
|
188
|
-
out.write(`\x1b[${target - cursor}C`);
|
|
189
227
|
cursor = target;
|
|
228
|
+
render();
|
|
190
229
|
}
|
|
191
230
|
}
|
|
192
231
|
else if (cursor < buf.length) {
|
|
193
232
|
cursor++;
|
|
194
|
-
|
|
233
|
+
render();
|
|
195
234
|
}
|
|
196
235
|
}
|
|
197
236
|
else if (final === "H") {
|
|
198
|
-
// Home
|
|
199
237
|
if (cursor > 0) {
|
|
200
|
-
out.write(`\x1b[${cursor}D`);
|
|
201
238
|
cursor = 0;
|
|
239
|
+
render();
|
|
202
240
|
}
|
|
203
241
|
}
|
|
204
242
|
else if (final === "F") {
|
|
205
|
-
// End
|
|
206
243
|
if (cursor < buf.length) {
|
|
207
|
-
out.write(`\x1b[${buf.length - cursor}C`);
|
|
208
244
|
cursor = buf.length;
|
|
245
|
+
render();
|
|
209
246
|
}
|
|
210
247
|
}
|
|
211
248
|
else if (final === "u") {
|
|
@@ -220,7 +257,7 @@ function rawReadLine(prompt, belowLines) {
|
|
|
220
257
|
if (cursor > 0) {
|
|
221
258
|
buf = buf.slice(cursor);
|
|
222
259
|
cursor = 0;
|
|
223
|
-
|
|
260
|
+
render();
|
|
224
261
|
}
|
|
225
262
|
}
|
|
226
263
|
}
|
|
@@ -233,7 +270,7 @@ function rawReadLine(prompt, belowLines) {
|
|
|
233
270
|
if (code >= 32) {
|
|
234
271
|
buf = buf.slice(0, cursor) + chunk[i] + buf.slice(cursor);
|
|
235
272
|
cursor++;
|
|
236
|
-
|
|
273
|
+
render();
|
|
237
274
|
}
|
|
238
275
|
}
|
|
239
276
|
};
|
|
@@ -385,22 +422,22 @@ export async function startChat() {
|
|
|
385
422
|
process.stdout.write("\x1b[3A\r");
|
|
386
423
|
// Print top rule, then prompt with bottom rule + footer rendered below
|
|
387
424
|
console.log(rule);
|
|
388
|
-
const input = await rawReadLine(chalk.dim("❯ "), [rule, footerText]);
|
|
425
|
+
const { input, rows: promptRows } = await rawReadLine(chalk.dim("❯ "), [rule, footerText]);
|
|
389
426
|
const trimmed = input.trim();
|
|
390
427
|
if (!trimmed) {
|
|
391
|
-
// Clear the prompt frame (
|
|
392
|
-
process.stdout.write(
|
|
393
|
-
for (let i = 0; i <
|
|
428
|
+
// Clear the prompt frame (prompt + bottom rule + footer); leave top rule
|
|
429
|
+
process.stdout.write(`\x1b[${promptRows + 2}A\r`);
|
|
430
|
+
for (let i = 0; i < promptRows + 3; i++)
|
|
394
431
|
process.stdout.write("\x1b[2K\x1b[1B");
|
|
395
|
-
process.stdout.write(
|
|
432
|
+
process.stdout.write(`\x1b[${promptRows + 3}A\r`);
|
|
396
433
|
continue;
|
|
397
434
|
}
|
|
398
|
-
// Replace prompt frame with gray-
|
|
399
|
-
|
|
400
|
-
process.stdout.write(
|
|
401
|
-
for (let i = 0; i <
|
|
435
|
+
// Replace prompt frame (top rule + prompt + bottom rule + footer) with gray-bg user message
|
|
436
|
+
const frameRows = promptRows + 3;
|
|
437
|
+
process.stdout.write(`\x1b[${frameRows}A\r`);
|
|
438
|
+
for (let i = 0; i < frameRows; i++)
|
|
402
439
|
process.stdout.write("\x1b[2K\x1b[1B");
|
|
403
|
-
process.stdout.write(
|
|
440
|
+
process.stdout.write(`\x1b[${frameRows}A\r`);
|
|
404
441
|
// Print user message with gray background, padded to full width
|
|
405
442
|
const msgText = `❯ ${trimmed}`;
|
|
406
443
|
const pad = Math.max(0, cols - msgText.length);
|
package/dist/cli/commands.js
CHANGED
|
@@ -7,6 +7,7 @@ import { runDailySync } from "../daily-sync.js";
|
|
|
7
7
|
import { startLinkServer } from "../server.js";
|
|
8
8
|
import { addManualAccount, getManualAccounts, removeManualAccount, scrapeRedfinEstimate } from "../property.js";
|
|
9
9
|
import { heading, progressBar, formatMoney, formatMoneyColored, dim, formatDuration, formatError, renderLogo, institutionName } from "./format.js";
|
|
10
|
+
import { getUpcomingBills } from "../db/bills.js";
|
|
10
11
|
export async function runSync() {
|
|
11
12
|
const ora = (await import("ora")).default;
|
|
12
13
|
const spinner = ora("Syncing transactions...").start();
|
|
@@ -429,20 +430,7 @@ export function showAlerts() {
|
|
|
429
430
|
}
|
|
430
431
|
export function showBills(days = 7) {
|
|
431
432
|
const db = getDb();
|
|
432
|
-
const
|
|
433
|
-
const todayDay = now.getDate();
|
|
434
|
-
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
435
|
-
const endDay = todayDay + days;
|
|
436
|
-
let bills = [];
|
|
437
|
-
if (endDay <= daysInMonth) {
|
|
438
|
-
bills = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ? ORDER BY day_of_month`).all(todayDay + 1, endDay);
|
|
439
|
-
}
|
|
440
|
-
else {
|
|
441
|
-
// Wraparound into next month
|
|
442
|
-
const thisMonth = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN ? AND ? ORDER BY day_of_month`).all(todayDay + 1, daysInMonth);
|
|
443
|
-
const nextMonth = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month BETWEEN 1 AND ? ORDER BY day_of_month`).all(endDay - daysInMonth);
|
|
444
|
-
bills = [...thisMonth, ...nextMonth];
|
|
445
|
-
}
|
|
433
|
+
const bills = getUpcomingBills(db, days);
|
|
446
434
|
if (bills.length === 0) {
|
|
447
435
|
console.log(`\nNo upcoming bills in the next ${days} days.`);
|
|
448
436
|
return;
|
|
@@ -451,16 +439,13 @@ export function showBills(days = 7) {
|
|
|
451
439
|
const maxName = Math.max(...bills.map(b => b.name.length));
|
|
452
440
|
let total = 0;
|
|
453
441
|
for (const b of bills) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
462
|
-
const dateStr = billDate.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
463
|
-
console.log(` ${dim(dateStr.padEnd(8))}${b.name.padEnd(maxName + 2)}${rawFormatMoney(b.amount).padStart(10)}`);
|
|
442
|
+
const dateStr = b.date.toLocaleDateString("en-US", {
|
|
443
|
+
month: "short", day: "numeric", timeZone: "UTC",
|
|
444
|
+
});
|
|
445
|
+
const amountStr = rawFormatMoney(b.amount);
|
|
446
|
+
const noteStr = b.note ? dim(` ${b.note}`) : "";
|
|
447
|
+
const tag = dim(`[${b.source}]`);
|
|
448
|
+
console.log(` ${dim(dateStr.padEnd(8))}${b.name.padEnd(maxName + 2)}${amountStr.padStart(10)}${noteStr} ${tag}`);
|
|
464
449
|
total += b.amount;
|
|
465
450
|
}
|
|
466
451
|
console.log(`\n ${dim("Total due:".padEnd(maxName + 10))}${chalk.bold(rawFormatMoney(total))}`);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
export type BillSource = "card" | "recurring" | "manual";
|
|
3
|
+
export type UpcomingBill = {
|
|
4
|
+
date: Date;
|
|
5
|
+
name: string;
|
|
6
|
+
amount: number;
|
|
7
|
+
source: BillSource;
|
|
8
|
+
/** Optional secondary amount shown in parens, e.g. minimum payment for credit cards. */
|
|
9
|
+
note?: string;
|
|
10
|
+
};
|
|
11
|
+
export type PlaidFrequency = "WEEKLY" | "BIWEEKLY" | "SEMI_MONTHLY" | "MONTHLY" | "ANNUALLY" | "UNKNOWN" | string;
|
|
12
|
+
/**
|
|
13
|
+
* Predict the next occurrence after `lastDate` given Plaid's reported frequency.
|
|
14
|
+
* Returns null for UNKNOWN/unsupported frequencies.
|
|
15
|
+
*/
|
|
16
|
+
export declare function predictNextBillDate(lastDate: string, frequency: PlaidFrequency): Date | null;
|
|
17
|
+
/**
|
|
18
|
+
* Collect upcoming outflows from Plaid recurring streams, credit/mortgage/student
|
|
19
|
+
* liability due dates, and manual recurring_bills. Sorted by date ascending.
|
|
20
|
+
* Returns bills whose predicted date is between tomorrow and today+`days` inclusive.
|
|
21
|
+
*/
|
|
22
|
+
export declare function getUpcomingBills(db: Database.Database, days: number): UpcomingBill[];
|
package/dist/db/bills.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predict the next occurrence after `lastDate` given Plaid's reported frequency.
|
|
3
|
+
* Returns null for UNKNOWN/unsupported frequencies.
|
|
4
|
+
*/
|
|
5
|
+
export function predictNextBillDate(lastDate, frequency) {
|
|
6
|
+
const last = new Date(lastDate + "T00:00:00Z");
|
|
7
|
+
if (isNaN(last.getTime()))
|
|
8
|
+
return null;
|
|
9
|
+
switch (frequency) {
|
|
10
|
+
case "WEEKLY":
|
|
11
|
+
return addDays(last, 7);
|
|
12
|
+
case "BIWEEKLY":
|
|
13
|
+
return addDays(last, 14);
|
|
14
|
+
case "SEMI_MONTHLY": {
|
|
15
|
+
// Paychecks/bills on the 1st and 15th of the month.
|
|
16
|
+
const d = last.getUTCDate();
|
|
17
|
+
if (d < 15)
|
|
18
|
+
return new Date(Date.UTC(last.getUTCFullYear(), last.getUTCMonth(), 15));
|
|
19
|
+
return new Date(Date.UTC(last.getUTCFullYear(), last.getUTCMonth() + 1, 1));
|
|
20
|
+
}
|
|
21
|
+
case "MONTHLY":
|
|
22
|
+
return addMonths(last, 1);
|
|
23
|
+
case "ANNUALLY":
|
|
24
|
+
return addMonths(last, 12);
|
|
25
|
+
default:
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function addDays(d, n) {
|
|
30
|
+
return new Date(d.getTime() + n * 86400000);
|
|
31
|
+
}
|
|
32
|
+
/** Add months, clamping to the last day of the target month (Jan 31 + 1mo → Feb 28/29). */
|
|
33
|
+
function addMonths(d, n) {
|
|
34
|
+
const y = d.getUTCFullYear();
|
|
35
|
+
const m = d.getUTCMonth() + n;
|
|
36
|
+
const day = d.getUTCDate();
|
|
37
|
+
const daysInTarget = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
|
|
38
|
+
return new Date(Date.UTC(y, m, Math.min(day, daysInTarget)));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Collect upcoming outflows from Plaid recurring streams, credit/mortgage/student
|
|
42
|
+
* liability due dates, and manual recurring_bills. Sorted by date ascending.
|
|
43
|
+
* Returns bills whose predicted date is between tomorrow and today+`days` inclusive.
|
|
44
|
+
*/
|
|
45
|
+
export function getUpcomingBills(db, days) {
|
|
46
|
+
const now = new Date();
|
|
47
|
+
const today = startOfUtcDay(now);
|
|
48
|
+
const windowStart = addDays(today, 1);
|
|
49
|
+
const windowEnd = addDays(today, days);
|
|
50
|
+
const bills = [];
|
|
51
|
+
// 1. Plaid recurring streams (outflows only)
|
|
52
|
+
const streams = db.prepare(`SELECT description, merchant_name, frequency, avg_amount, last_amount, last_date
|
|
53
|
+
FROM recurring
|
|
54
|
+
WHERE is_active = 1 AND stream_type = 'outflow' AND last_date IS NOT NULL`).all();
|
|
55
|
+
for (const s of streams) {
|
|
56
|
+
const next = predictNextBillDate(s.last_date, s.frequency);
|
|
57
|
+
if (!next)
|
|
58
|
+
continue;
|
|
59
|
+
if (next < windowStart || next > windowEnd)
|
|
60
|
+
continue;
|
|
61
|
+
const amount = Math.abs(s.last_amount ?? s.avg_amount ?? 0);
|
|
62
|
+
if (amount === 0)
|
|
63
|
+
continue;
|
|
64
|
+
bills.push({
|
|
65
|
+
date: next,
|
|
66
|
+
name: s.merchant_name || s.description,
|
|
67
|
+
amount,
|
|
68
|
+
source: "recurring",
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// 2. Liabilities with a scheduled due date
|
|
72
|
+
const liabs = db.prepare(`SELECT l.type, l.current_balance, l.minimum_payment, l.next_payment_due, a.name as account_name
|
|
73
|
+
FROM liabilities l
|
|
74
|
+
JOIN accounts a ON a.account_id = l.account_id
|
|
75
|
+
WHERE l.next_payment_due IS NOT NULL`).all();
|
|
76
|
+
for (const l of liabs) {
|
|
77
|
+
const due = new Date(l.next_payment_due + "T00:00:00Z");
|
|
78
|
+
if (isNaN(due.getTime()))
|
|
79
|
+
continue;
|
|
80
|
+
if (due < windowStart || due > windowEnd)
|
|
81
|
+
continue;
|
|
82
|
+
if (l.type === "credit") {
|
|
83
|
+
const stmt = l.current_balance ?? 0;
|
|
84
|
+
if (stmt <= 0)
|
|
85
|
+
continue;
|
|
86
|
+
bills.push({
|
|
87
|
+
date: due,
|
|
88
|
+
name: l.account_name,
|
|
89
|
+
amount: stmt,
|
|
90
|
+
source: "card",
|
|
91
|
+
note: l.minimum_payment != null ? `min ${formatShortMoney(l.minimum_payment)}` : undefined,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
const min = l.minimum_payment ?? 0;
|
|
96
|
+
if (min <= 0)
|
|
97
|
+
continue;
|
|
98
|
+
bills.push({
|
|
99
|
+
date: due,
|
|
100
|
+
name: l.account_name,
|
|
101
|
+
amount: min,
|
|
102
|
+
source: "card",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 3. Manual recurring_bills (month/day schedule)
|
|
107
|
+
const manual = db.prepare(`SELECT name, amount, day_of_month FROM recurring_bills WHERE day_of_month IS NOT NULL`).all();
|
|
108
|
+
for (const m of manual) {
|
|
109
|
+
const next = nextDayOfMonthDate(today, m.day_of_month);
|
|
110
|
+
if (next < windowStart || next > windowEnd)
|
|
111
|
+
continue;
|
|
112
|
+
bills.push({ date: next, name: m.name, amount: m.amount, source: "manual" });
|
|
113
|
+
}
|
|
114
|
+
bills.sort((a, b) => a.date.getTime() - b.date.getTime());
|
|
115
|
+
return bills;
|
|
116
|
+
}
|
|
117
|
+
function startOfUtcDay(d) {
|
|
118
|
+
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
|
|
119
|
+
}
|
|
120
|
+
/** Next calendar date matching the given day-of-month, clamping to month length. */
|
|
121
|
+
function nextDayOfMonthDate(today, dayOfMonth) {
|
|
122
|
+
const y = today.getUTCFullYear();
|
|
123
|
+
const m = today.getUTCMonth();
|
|
124
|
+
const daysThisMonth = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
|
|
125
|
+
const clampedThisMonth = Math.min(dayOfMonth, daysThisMonth);
|
|
126
|
+
const candidate = new Date(Date.UTC(y, m, clampedThisMonth));
|
|
127
|
+
if (candidate > today)
|
|
128
|
+
return candidate;
|
|
129
|
+
const daysNextMonth = new Date(Date.UTC(y, m + 2, 0)).getUTCDate();
|
|
130
|
+
return new Date(Date.UTC(y, m + 1, Math.min(dayOfMonth, daysNextMonth)));
|
|
131
|
+
}
|
|
132
|
+
function formatShortMoney(n) {
|
|
133
|
+
return `$${Math.round(n).toLocaleString()}`;
|
|
134
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type BetterSqlite3 from "libsql";
|
|
2
|
+
type Database = BetterSqlite3.Database;
|
|
3
|
+
export interface RecategorizationResult {
|
|
4
|
+
rulesEvaluated: number;
|
|
5
|
+
rulesSkipped: number;
|
|
6
|
+
transactionsUpdated: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Apply every row in `recategorization_rules` as an UPDATE against
|
|
10
|
+
* `transactions`. Called from both `runDailySync` and `runImportApple`; owns
|
|
11
|
+
* its own console output so both callers produce identical per-rule lines
|
|
12
|
+
* and the grand-total summary. Silent when no rules fire.
|
|
13
|
+
*/
|
|
14
|
+
export declare function applyRecategorizationRules(db: Database): RecategorizationResult;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Only known column names are allowed in `match_field` — the value flows into
|
|
2
|
+
// the UPDATE statement via string interpolation (rest of the query is
|
|
3
|
+
// parametrized). Anything else is rejected to prevent SQL injection.
|
|
4
|
+
const ALLOWED_MATCH_FIELDS = ["name", "merchant_name", "category", "subcategory"];
|
|
5
|
+
/**
|
|
6
|
+
* Apply every row in `recategorization_rules` as an UPDATE against
|
|
7
|
+
* `transactions`. Called from both `runDailySync` and `runImportApple`; owns
|
|
8
|
+
* its own console output so both callers produce identical per-rule lines
|
|
9
|
+
* and the grand-total summary. Silent when no rules fire.
|
|
10
|
+
*/
|
|
11
|
+
export function applyRecategorizationRules(db) {
|
|
12
|
+
const rules = db.prepare(`SELECT match_field, match_pattern, target_category, target_subcategory, label FROM recategorization_rules`).all();
|
|
13
|
+
let rulesSkipped = 0;
|
|
14
|
+
let transactionsUpdated = 0;
|
|
15
|
+
for (const rule of rules) {
|
|
16
|
+
if (!ALLOWED_MATCH_FIELDS.includes(rule.match_field)) {
|
|
17
|
+
console.error(` Skipping recat rule with invalid match_field: ${rule.match_field}`);
|
|
18
|
+
rulesSkipped++;
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
// Guard fires whenever the row isn't "already at target". Both branches
|
|
22
|
+
// write the full (category, subcategory) pair so a rule never leaves a
|
|
23
|
+
// stale subcategory attached to a new category (e.g. an "Amazon ->
|
|
24
|
+
// GENERAL_MERCHANDISE" rule applied to a row tagged
|
|
25
|
+
// FOOD_AND_DRINK / FOOD_AND_DRINK_GROCERIES). COALESCE is load-bearing on
|
|
26
|
+
// any nullable field we compare with `!=`: plain `!=` against NULL yields
|
|
27
|
+
// NULL (falsy in SQLite three-valued logic) and would silently exclude
|
|
28
|
+
// rows whose category or subcategory is NULL. Apple imports produce such
|
|
29
|
+
// rows for "Other" and any unmapped Apple category.
|
|
30
|
+
const result = rule.target_subcategory
|
|
31
|
+
? db.prepare(`UPDATE transactions SET category = ?, subcategory = ? WHERE ${rule.match_field} LIKE ? AND (COALESCE(category, '') != ? OR COALESCE(subcategory, '') != ?)`).run(rule.target_category, rule.target_subcategory, rule.match_pattern, rule.target_category, rule.target_subcategory)
|
|
32
|
+
: db.prepare(`UPDATE transactions SET category = ?, subcategory = NULL WHERE ${rule.match_field} LIKE ? AND (COALESCE(category, '') != ? OR subcategory IS NOT NULL)`).run(rule.target_category, rule.match_pattern, rule.target_category);
|
|
33
|
+
if (result.changes > 0) {
|
|
34
|
+
console.log(` Recategorized ${result.changes} txn(s): ${rule.label || rule.match_pattern}`);
|
|
35
|
+
transactionsUpdated += Number(result.changes);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (transactionsUpdated > 0) {
|
|
39
|
+
console.log(` Auto-recategorized ${transactionsUpdated} transaction(s).`);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
rulesEvaluated: rules.length,
|
|
43
|
+
rulesSkipped,
|
|
44
|
+
transactionsUpdated,
|
|
45
|
+
};
|
|
46
|
+
}
|