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.
@@ -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
- // Recurring bills due in next 7 days
142
- const now = new Date();
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.day_of_month > todayDay
167
- ? b.day_of_month - todayDay
168
- : daysInMonth - todayDay + b.day_of_month;
169
- return `${b.name} (${formatMoney(b.amount)}) due in ${daysUntil} days`;
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 todayDay = now.getDate();
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 = b.day_of_month > todayDay ? b.day_of_month - todayDay : daysInMonth - todayDay + b.day_of_month;
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
- // Redraws the buffer from the prompt onward and repositions the cursor
12
- const redraw = () => {
13
- out.write("\r" + prompt + buf + "\x1b[K"); // clear to end of line
14
- // Move cursor back to the correct position
15
- const back = buf.length - cursor;
16
- if (back > 0)
17
- out.write(`\x1b[${back}D`);
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
- // Render: prompt on current line, then content below, then move cursor back
38
- out.write(prompt);
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
- buf = buf.slice(0, cursor);
82
- out.write("\x1b[K");
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
- buf = buf.slice(cursor);
88
- cursor = 0;
89
- redraw();
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
- redraw();
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 first
106
- if (cursor < buf.length)
107
- out.write(`\x1b[${buf.length - cursor}C`);
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
- redraw();
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
- redraw();
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
- out.write("\x1b[D");
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
- out.write("\x1b[C");
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
- redraw();
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
- redraw();
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 (top rule + prompt + bottom rule + footer)
392
- process.stdout.write("\x1b[3A\r");
393
- for (let i = 0; i < 4; 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("\x1b[4A\r");
432
+ process.stdout.write(`\x1b[${promptRows + 3}A\r`);
396
433
  continue;
397
434
  }
398
- // Replace prompt frame with gray-background user message
399
- // Move up 4 lines (footer, bottom rule, prompt, top rule) and clear them
400
- process.stdout.write("\x1b[4A\r");
401
- for (let i = 0; i < 4; 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("\x1b[4A\r");
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);
@@ -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 now = new Date();
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
- // Calculate the actual date for this bill
455
- let billDate;
456
- if (b.day_of_month > todayDay) {
457
- billDate = new Date(now.getFullYear(), now.getMonth(), b.day_of_month);
458
- }
459
- else {
460
- billDate = new Date(now.getFullYear(), now.getMonth() + 1, b.day_of_month);
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[];
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ray-finance",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
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",