ray-finance 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/dist/ai/agent.d.ts +5 -1
  2. package/dist/ai/agent.js +20 -1
  3. package/dist/ai/insights.js +13 -39
  4. package/dist/ai/provider.d.ts +1 -0
  5. package/dist/ai/providers/anthropic.js +3 -1
  6. package/dist/ai/providers/openai-compat.js +2 -2
  7. package/dist/apple-import.d.ts +53 -0
  8. package/dist/apple-import.js +372 -0
  9. package/dist/cli/chat.d.ts +4 -0
  10. package/dist/cli/chat.js +11 -390
  11. package/dist/cli/commands.js +10 -25
  12. package/dist/cli/ink/ChatApp.d.ts +8 -0
  13. package/dist/cli/ink/ChatApp.js +96 -0
  14. package/dist/cli/ink/PromptFrame.d.ts +10 -0
  15. package/dist/cli/ink/PromptFrame.js +11 -0
  16. package/dist/cli/ink/TextInput.d.ts +13 -0
  17. package/dist/cli/ink/TextInput.js +24 -0
  18. package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
  19. package/dist/cli/ink/hooks/useAgent.js +77 -0
  20. package/dist/cli/ink/hooks/useBackgroundSync.d.ts +3 -0
  21. package/dist/cli/ink/hooks/useBackgroundSync.js +31 -0
  22. package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
  23. package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
  24. package/dist/cli/ink/hooks/useFooterText.d.ts +3 -0
  25. package/dist/cli/ink/hooks/useFooterText.js +47 -0
  26. package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
  27. package/dist/cli/ink/hooks/useTextInput.js +356 -0
  28. package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
  29. package/dist/cli/ink/messages/AssistantMessage.js +6 -0
  30. package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
  31. package/dist/cli/ink/messages/ErrorMessage.js +6 -0
  32. package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
  33. package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
  34. package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
  35. package/dist/cli/ink/messages/ThinkingLine.js +23 -0
  36. package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
  37. package/dist/cli/ink/messages/UserMessage.js +15 -0
  38. package/dist/cli/ink/mount.d.ts +6 -0
  39. package/dist/cli/ink/mount.js +12 -0
  40. package/dist/daily-sync.d.ts +6 -1
  41. package/dist/daily-sync.js +25 -24
  42. package/dist/db/bills.d.ts +22 -0
  43. package/dist/db/bills.js +134 -0
  44. package/dist/queries/index.d.ts +2 -0
  45. package/dist/queries/index.js +14 -5
  46. package/dist/recategorization.d.ts +15 -0
  47. package/dist/recategorization.js +46 -0
  48. package/package.json +5 -1
@@ -7,4 +7,8 @@ export type ProgressCallback = (event: {
7
7
  toolCount: number;
8
8
  elapsedMs: number;
9
9
  }) => void;
10
- export declare function handleMessage(db: Database.Database, userMessage: string, onProgress?: ProgressCallback): Promise<string>;
10
+ /** Thrown by handleMessage when the caller aborts via AbortSignal */
11
+ export declare class AbortedError extends Error {
12
+ constructor();
13
+ }
14
+ export declare function handleMessage(db: Database.Database, userMessage: string, onProgress?: ProgressCallback, signal?: AbortSignal): Promise<string>;
package/dist/ai/agent.js CHANGED
@@ -26,7 +26,14 @@ export const TOOL_LABELS = {
26
26
  save_memory: "Remembering that",
27
27
  update_context: "Updating your profile",
28
28
  };
29
- export async function handleMessage(db, userMessage, onProgress) {
29
+ /** Thrown by handleMessage when the caller aborts via AbortSignal */
30
+ export class AbortedError extends Error {
31
+ constructor() {
32
+ super("aborted");
33
+ this.name = "AbortedError";
34
+ }
35
+ }
36
+ export async function handleMessage(db, userMessage, onProgress, signal) {
30
37
  // Save incoming message
31
38
  saveMessage(db, "user", userMessage);
32
39
  // Load conversation context, truncated to fit token budget
@@ -55,7 +62,12 @@ export async function handleMessage(db, userMessage, onProgress) {
55
62
  const useThinking = config.thinkingBudget > 0
56
63
  && provider.supportsThinking
57
64
  && supportsThinking(config.model);
65
+ const throwIfAborted = () => {
66
+ if (signal?.aborted)
67
+ throw new AbortedError();
68
+ };
58
69
  try {
70
+ throwIfAborted();
59
71
  // Initial API call
60
72
  let response = await provider.sendMessage({
61
73
  model: config.model,
@@ -66,11 +78,13 @@ export async function handleMessage(db, userMessage, onProgress) {
66
78
  thinking: useThinking
67
79
  ? { type: "enabled", budget_tokens: config.thinkingBudget }
68
80
  : undefined,
81
+ signal,
69
82
  });
70
83
  // Agentic tool loop
71
84
  const startTime = Date.now();
72
85
  let toolCount = 0;
73
86
  while (response.stopReason === "tool_use" && toolCount < MAX_TOOL_STEPS) {
87
+ throwIfAborted();
74
88
  messages.push({ role: "assistant", content: response.content });
75
89
  const toolResults = [];
76
90
  for (const block of response.content) {
@@ -97,6 +111,7 @@ export async function handleMessage(db, userMessage, onProgress) {
97
111
  toolCount,
98
112
  elapsedMs: Date.now() - startTime,
99
113
  });
114
+ throwIfAborted();
100
115
  response = await provider.sendMessage({
101
116
  model: config.model,
102
117
  maxTokens: useThinking ? 16000 : 4096,
@@ -106,6 +121,7 @@ export async function handleMessage(db, userMessage, onProgress) {
106
121
  thinking: useThinking
107
122
  ? { type: "enabled", budget_tokens: config.thinkingBudget }
108
123
  : undefined,
124
+ signal,
109
125
  });
110
126
  }
111
127
  // Extract text response, restore PII for display
@@ -116,6 +132,9 @@ export async function handleMessage(db, userMessage, onProgress) {
116
132
  return responseText || "I looked into that but couldn't formulate a response. Could you try rephrasing?";
117
133
  }
118
134
  catch (error) {
135
+ if (error instanceof AbortedError || error?.name === "AbortError" || signal?.aborted) {
136
+ throw new AbortedError();
137
+ }
119
138
  if (error.status === 403) {
120
139
  if (useManaged()) {
121
140
  return "Your API key was rejected. This usually means your subscription is inactive. Run `ray billing` to check your payment status, or `ray setup` to reconfigure.";
@@ -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));
@@ -50,6 +50,7 @@ export interface SendMessageParams {
50
50
  type: "enabled";
51
51
  budget_tokens: number;
52
52
  };
53
+ signal?: AbortSignal;
53
54
  }
54
55
  export interface Provider {
55
56
  name: string;
@@ -17,7 +17,9 @@ export function createAnthropicProvider(opts) {
17
17
  if (params.thinking) {
18
18
  apiParams.thinking = params.thinking;
19
19
  }
20
- const response = await client.messages.create(apiParams);
20
+ const response = await client.messages.create(apiParams, {
21
+ signal: params.signal,
22
+ });
21
23
  // Filter thinking blocks and normalize content
22
24
  const content = [];
23
25
  for (const block of response.content) {
@@ -19,7 +19,7 @@ export function createOpenAICompatibleProvider(opts) {
19
19
  max_tokens: params.maxTokens,
20
20
  messages,
21
21
  tools: tools.length > 0 ? tools : undefined,
22
- });
22
+ }, { signal: params.signal });
23
23
  }
24
24
  catch (e) {
25
25
  if (e.status === 400 && e.message?.includes("max_tokens")) {
@@ -28,7 +28,7 @@ export function createOpenAICompatibleProvider(opts) {
28
28
  max_completion_tokens: params.maxTokens,
29
29
  messages,
30
30
  tools: tools.length > 0 ? tools : undefined,
31
- });
31
+ }, { signal: params.signal });
32
32
  }
33
33
  else {
34
34
  throw e;
@@ -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
+ }
@@ -1 +1,5 @@
1
+ /**
2
+ * Pre-mount orchestration: banner, briefing, account check + optional runLink,
3
+ * then hand off to the Ink-rendered ChatApp.
4
+ */
1
5
  export declare function startChat(): Promise<void>;