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.
- package/dist/ai/agent.d.ts +5 -1
- package/dist/ai/agent.js +20 -1
- package/dist/ai/insights.js +13 -39
- package/dist/ai/provider.d.ts +1 -0
- package/dist/ai/providers/anthropic.js +3 -1
- package/dist/ai/providers/openai-compat.js +2 -2
- package/dist/apple-import.d.ts +53 -0
- package/dist/apple-import.js +372 -0
- package/dist/cli/chat.d.ts +4 -0
- package/dist/cli/chat.js +11 -390
- package/dist/cli/commands.js +10 -25
- package/dist/cli/ink/ChatApp.d.ts +8 -0
- package/dist/cli/ink/ChatApp.js +96 -0
- package/dist/cli/ink/PromptFrame.d.ts +10 -0
- package/dist/cli/ink/PromptFrame.js +11 -0
- package/dist/cli/ink/TextInput.d.ts +13 -0
- package/dist/cli/ink/TextInput.js +24 -0
- package/dist/cli/ink/hooks/useAgent.d.ts +27 -0
- package/dist/cli/ink/hooks/useAgent.js +77 -0
- package/dist/cli/ink/hooks/useBackgroundSync.d.ts +3 -0
- package/dist/cli/ink/hooks/useBackgroundSync.js +31 -0
- package/dist/cli/ink/hooks/useCtrlCExit.d.ts +16 -0
- package/dist/cli/ink/hooks/useCtrlCExit.js +43 -0
- package/dist/cli/ink/hooks/useFooterText.d.ts +3 -0
- package/dist/cli/ink/hooks/useFooterText.js +47 -0
- package/dist/cli/ink/hooks/useTextInput.d.ts +32 -0
- package/dist/cli/ink/hooks/useTextInput.js +356 -0
- package/dist/cli/ink/messages/AssistantMessage.d.ts +3 -0
- package/dist/cli/ink/messages/AssistantMessage.js +6 -0
- package/dist/cli/ink/messages/ErrorMessage.d.ts +4 -0
- package/dist/cli/ink/messages/ErrorMessage.js +6 -0
- package/dist/cli/ink/messages/InterruptedMessage.d.ts +1 -0
- package/dist/cli/ink/messages/InterruptedMessage.js +6 -0
- package/dist/cli/ink/messages/ThinkingLine.d.ts +12 -0
- package/dist/cli/ink/messages/ThinkingLine.js +23 -0
- package/dist/cli/ink/messages/UserMessage.d.ts +4 -0
- package/dist/cli/ink/messages/UserMessage.js +15 -0
- package/dist/cli/ink/mount.d.ts +6 -0
- package/dist/cli/ink/mount.js +12 -0
- package/dist/daily-sync.d.ts +6 -1
- package/dist/daily-sync.js +25 -24
- package/dist/db/bills.d.ts +22 -0
- package/dist/db/bills.js +134 -0
- package/dist/queries/index.d.ts +2 -0
- package/dist/queries/index.js +14 -5
- package/dist/recategorization.d.ts +15 -0
- package/dist/recategorization.js +46 -0
- package/package.json +5 -1
package/dist/ai/agent.d.ts
CHANGED
|
@@ -7,4 +7,8 @@ export type ProgressCallback = (event: {
|
|
|
7
7
|
toolCount: number;
|
|
8
8
|
elapsedMs: number;
|
|
9
9
|
}) => void;
|
|
10
|
-
|
|
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
|
-
|
|
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.";
|
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));
|
package/dist/ai/provider.d.ts
CHANGED
|
@@ -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
|
+
}
|