plasalid 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -14
- package/dist/ai/agent.d.ts +15 -2
- package/dist/ai/agent.js +21 -2
- package/dist/ai/memory.d.ts +2 -0
- package/dist/ai/memory.js +2 -2
- package/dist/ai/personas.d.ts +2 -1
- package/dist/ai/personas.js +115 -45
- package/dist/ai/prompt-sections.d.ts +5 -0
- package/dist/ai/prompt-sections.js +26 -8
- package/dist/ai/system-prompt.d.ts +11 -0
- package/dist/ai/system-prompt.js +21 -6
- package/dist/ai/thinking.js +1 -1
- package/dist/ai/tools/common.js +2 -5
- package/dist/ai/tools/index.js +28 -8
- package/dist/ai/tools/ingest.d.ts +2 -1
- package/dist/ai/tools/ingest.js +262 -151
- package/dist/ai/tools/merchants.d.ts +2 -0
- package/dist/ai/tools/merchants.js +117 -0
- package/dist/ai/tools/read.js +31 -29
- package/dist/ai/tools/record.d.ts +2 -0
- package/dist/ai/tools/record.js +188 -0
- package/dist/ai/tools/review.js +77 -80
- package/dist/ai/tools/scan.js +1 -1
- package/dist/ai/tools/types.d.ts +15 -6
- package/dist/cli/commands/accounts.js +33 -25
- package/dist/cli/commands/record.d.ts +4 -0
- package/dist/cli/commands/record.js +119 -0
- package/dist/cli/commands/revert.js +1 -1
- package/dist/cli/commands/scan.js +15 -19
- package/dist/cli/commands/status.js +6 -9
- package/dist/cli/commands/transactions.js +36 -41
- package/dist/cli/format.d.ts +2 -0
- package/dist/cli/format.js +7 -2
- package/dist/cli/index.js +19 -7
- package/dist/cli/ink/hooks/useFooterText.js +2 -1
- package/dist/cli/ink/scan_dashboard.d.ts +1 -1
- package/dist/cli/ink/scan_dashboard.js +2 -2
- package/dist/cli/setup.d.ts +0 -1
- package/dist/cli/setup.js +2 -8
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +12 -1
- package/dist/db/queries/account_balance.d.ts +83 -4
- package/dist/db/queries/account_balance.js +239 -20
- package/dist/db/queries/action_log.d.ts +29 -0
- package/dist/db/queries/action_log.js +27 -0
- package/dist/db/queries/concerns.d.ts +10 -7
- package/dist/db/queries/concerns.js +20 -16
- package/dist/db/queries/journal.d.ts +1 -0
- package/dist/db/queries/merchants.d.ts +42 -0
- package/dist/db/queries/merchants.js +120 -0
- package/dist/db/queries/recurrences.d.ts +3 -3
- package/dist/db/queries/recurrences.js +32 -34
- package/dist/db/queries/search.d.ts +5 -4
- package/dist/db/queries/search.js +16 -12
- package/dist/db/queries/transactions.d.ts +167 -0
- package/dist/db/queries/transactions.js +320 -0
- package/dist/db/schema.js +51 -9
- package/dist/reviewer/pipeline.d.ts +4 -4
- package/dist/reviewer/pipeline.js +4 -4
- package/dist/reviewer/prompts.js +4 -4
- package/dist/scanner/buffer.d.ts +24 -21
- package/dist/scanner/buffer.js +18 -18
- package/dist/scanner/pipeline.d.ts +3 -2
- package/dist/scanner/pipeline.js +33 -36
- package/dist/scanner/prompts.js +3 -3
- package/package.json +2 -2
|
@@ -22,7 +22,13 @@ export async function runScanCommand(opts) {
|
|
|
22
22
|
});
|
|
23
23
|
renderScanSummary(summary);
|
|
24
24
|
}
|
|
25
|
-
|
|
25
|
+
function logDecryptProgress(e) {
|
|
26
|
+
const marker = e.outcome === "decrypted" ? chalk.dim("·")
|
|
27
|
+
: e.outcome === "skipped" ? chalk.dim("•")
|
|
28
|
+
: chalk.red("✗");
|
|
29
|
+
console.log(` ${marker} [${e.index + 1}/${e.total}] ${e.fileName} (${e.outcome})`);
|
|
30
|
+
}
|
|
31
|
+
/** Ink-based events (TTY mode) */
|
|
26
32
|
async function buildInkEvents(parallel) {
|
|
27
33
|
// Lazy-load ink + react so this module stays importable in non-TTY contexts
|
|
28
34
|
// (and so test environments without React don't choke on the JSX).
|
|
@@ -37,12 +43,7 @@ async function buildInkEvents(parallel) {
|
|
|
37
43
|
if (count > 0)
|
|
38
44
|
console.log(chalk.dim(`Decrypting ${count} file(s)...`));
|
|
39
45
|
},
|
|
40
|
-
decryptProgress:
|
|
41
|
-
const marker = e.outcome === "decrypted" ? chalk.dim("·")
|
|
42
|
-
: e.outcome === "skipped" ? chalk.dim("•")
|
|
43
|
-
: chalk.red("✗");
|
|
44
|
-
console.log(` ${marker} [${e.index + 1}/${e.total}] ${e.fileName} (${e.outcome})`);
|
|
45
|
-
},
|
|
46
|
+
decryptProgress: logDecryptProgress,
|
|
46
47
|
decryptDone: (e) => {
|
|
47
48
|
console.log(chalk.dim(`Decrypted ${e.decrypted}, skipped ${e.skipped}, failed ${e.failed}.`));
|
|
48
49
|
console.log("");
|
|
@@ -57,7 +58,7 @@ async function buildInkEvents(parallel) {
|
|
|
57
58
|
type: "scan-end",
|
|
58
59
|
fileName: e.fileName,
|
|
59
60
|
status: e.status,
|
|
60
|
-
|
|
61
|
+
transactions: e.transactions,
|
|
61
62
|
concerns: e.concerns,
|
|
62
63
|
error: e.error,
|
|
63
64
|
}),
|
|
@@ -81,7 +82,7 @@ async function buildInkEvents(parallel) {
|
|
|
81
82
|
},
|
|
82
83
|
};
|
|
83
84
|
}
|
|
84
|
-
|
|
85
|
+
/** Plain-text progress (non-TTY or fallback) */
|
|
85
86
|
function buildPlainTextEvents() {
|
|
86
87
|
let decryptTotal = 0;
|
|
87
88
|
// De-dupe scan-progress chatter: only print when the step text changes per file.
|
|
@@ -92,12 +93,7 @@ function buildPlainTextEvents() {
|
|
|
92
93
|
if (count > 0)
|
|
93
94
|
console.log(chalk.dim(`Decrypting ${count} file(s)...`));
|
|
94
95
|
},
|
|
95
|
-
decryptProgress:
|
|
96
|
-
const marker = e.outcome === "decrypted" ? chalk.dim("·")
|
|
97
|
-
: e.outcome === "skipped" ? chalk.dim("•")
|
|
98
|
-
: chalk.red("✗");
|
|
99
|
-
console.log(` ${marker} [${e.index + 1}/${e.total}] ${e.fileName} (${e.outcome})`);
|
|
100
|
-
},
|
|
96
|
+
decryptProgress: logDecryptProgress,
|
|
101
97
|
decryptDone: (e) => {
|
|
102
98
|
if (decryptTotal === 0)
|
|
103
99
|
return;
|
|
@@ -116,7 +112,7 @@ function buildPlainTextEvents() {
|
|
|
116
112
|
scanEnd: (e) => {
|
|
117
113
|
lastStepByFile.delete(e.fileName);
|
|
118
114
|
if (e.status === "scanned") {
|
|
119
|
-
console.log(`${chalk.green("✓")} ${e.fileName} ${chalk.dim(`(${e.
|
|
115
|
+
console.log(`${chalk.green("✓")} ${e.fileName} ${chalk.dim(`(${e.transactions} transactions, ${e.concerns} concerns)`)}`);
|
|
120
116
|
}
|
|
121
117
|
else {
|
|
122
118
|
console.log(`${chalk.red("✗")} ${e.fileName} ${chalk.dim(`— ${e.error ?? "failed"}`)}`);
|
|
@@ -131,7 +127,7 @@ function buildPlainTextEvents() {
|
|
|
131
127
|
},
|
|
132
128
|
};
|
|
133
129
|
}
|
|
134
|
-
|
|
130
|
+
/** Terse summary */
|
|
135
131
|
function renderScanSummary(summary) {
|
|
136
132
|
console.log("");
|
|
137
133
|
const headline = `Scanned ${summary.total} file(s) — ` +
|
|
@@ -144,12 +140,12 @@ function renderScanSummary(summary) {
|
|
|
144
140
|
const label = d.relPath;
|
|
145
141
|
switch (d.status) {
|
|
146
142
|
case "scanned": {
|
|
147
|
-
const tag = chalk.dim(`${d.
|
|
143
|
+
const tag = chalk.dim(`${d.transactions} transactions${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""}`);
|
|
148
144
|
console.log(` ${chalk.green("✓")} ${label} ${tag}`);
|
|
149
145
|
break;
|
|
150
146
|
}
|
|
151
147
|
case "replaced": {
|
|
152
|
-
const tag = chalk.dim(`${d.
|
|
148
|
+
const tag = chalk.dim(`${d.transactions} transactions${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""} (replaces prior)`);
|
|
153
149
|
console.log(` ${chalk.cyan("↻")} ${label} ${tag}`);
|
|
154
150
|
break;
|
|
155
151
|
}
|
|
@@ -1,22 +1,19 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
3
|
import { getNetWorth, getPeriodTotals } from "../../db/queries/account_balance.js";
|
|
4
|
-
import {
|
|
5
|
-
function fmt(n) {
|
|
6
|
-
return formatCurrencyAmount(n, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
7
|
-
}
|
|
4
|
+
import { formatAmount } from "../../currency.js";
|
|
8
5
|
export function showStatus() {
|
|
9
6
|
const db = getDb();
|
|
10
7
|
const nw = getNetWorth(db);
|
|
11
|
-
console.log(chalk.bold("Net worth: ") +
|
|
12
|
-
console.log(chalk.dim(`Assets ${
|
|
8
|
+
console.log(chalk.bold("Net worth: ") + formatAmount(nw.net_worth));
|
|
9
|
+
console.log(chalk.dim(`Assets ${formatAmount(nw.assets)} − Liabilities ${formatAmount(nw.liabilities)}`));
|
|
13
10
|
const now = new Date();
|
|
14
11
|
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10);
|
|
15
12
|
const today = now.toISOString().slice(0, 10);
|
|
16
13
|
const totals = getPeriodTotals(db, monthStart, today);
|
|
17
14
|
console.log("");
|
|
18
15
|
console.log(chalk.bold(`This month (${monthStart} → ${today})`));
|
|
19
|
-
console.log(` Income: ${
|
|
20
|
-
console.log(` Expenses: ${
|
|
21
|
-
console.log(` Net: ${
|
|
16
|
+
console.log(` Income: ${formatAmount(totals.income)}`);
|
|
17
|
+
console.log(` Expenses: ${formatAmount(totals.expenses)}`);
|
|
18
|
+
console.log(` Net: ${formatAmount(totals.income - totals.expenses)}`);
|
|
22
19
|
}
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
2
|
import { getDb } from "../../db/connection.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
return formatCurrencyAmount(n, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
7
|
-
}
|
|
8
|
-
// eslint-disable-next-line no-control-regex
|
|
9
|
-
const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
10
|
-
function visibleLength(s) {
|
|
11
|
-
return s.replace(ANSI_RE, "").length;
|
|
12
|
-
}
|
|
3
|
+
import { listPostings } from "../../db/queries/transactions.js";
|
|
4
|
+
import { visibleLength } from "../format.js";
|
|
5
|
+
import { formatAmount } from "../../currency.js";
|
|
13
6
|
function truncateMiddle(s, max) {
|
|
14
7
|
if (s.length <= max)
|
|
15
8
|
return s;
|
|
@@ -22,71 +15,73 @@ function truncateMiddle(s, max) {
|
|
|
22
15
|
}
|
|
23
16
|
const ACCOUNT_CAP = 32;
|
|
24
17
|
const MEMO_CAP = 40;
|
|
25
|
-
function
|
|
18
|
+
function groupByTransaction(postings) {
|
|
26
19
|
const groups = [];
|
|
27
20
|
let current = null;
|
|
28
|
-
for (const
|
|
29
|
-
if (!current || current.
|
|
21
|
+
for (const p of postings) {
|
|
22
|
+
if (!current || current.transaction_id !== p.transaction_id) {
|
|
30
23
|
current = {
|
|
31
|
-
|
|
32
|
-
date:
|
|
33
|
-
description:
|
|
34
|
-
|
|
24
|
+
transaction_id: p.transaction_id,
|
|
25
|
+
date: p.transaction_date ?? "",
|
|
26
|
+
description: p.transaction_description ?? "",
|
|
27
|
+
merchant: p.merchant_name ?? null,
|
|
28
|
+
postings: [],
|
|
35
29
|
};
|
|
36
30
|
groups.push(current);
|
|
37
31
|
}
|
|
38
|
-
current.
|
|
32
|
+
current.postings.push(p);
|
|
39
33
|
}
|
|
40
34
|
return groups;
|
|
41
35
|
}
|
|
42
36
|
export function showTransactions(opts) {
|
|
43
37
|
const db = getDb();
|
|
44
|
-
const
|
|
38
|
+
const postings = listPostings(db, {
|
|
45
39
|
account_id: opts.account,
|
|
46
40
|
from: opts.from,
|
|
47
41
|
to: opts.to,
|
|
48
42
|
q: opts.query,
|
|
49
43
|
limit: opts.limit ?? 100,
|
|
50
44
|
});
|
|
51
|
-
if (
|
|
52
|
-
console.log(chalk.yellow("No
|
|
45
|
+
if (postings.length === 0) {
|
|
46
|
+
console.log(chalk.yellow("No postings match those filters."));
|
|
53
47
|
return;
|
|
54
48
|
}
|
|
55
49
|
const truncatedAccount = new Map();
|
|
56
50
|
const truncatedMemo = new Map();
|
|
57
|
-
for (const
|
|
58
|
-
const acct =
|
|
59
|
-
truncatedAccount.set(
|
|
60
|
-
if (
|
|
61
|
-
truncatedMemo.set(
|
|
51
|
+
for (const p of postings) {
|
|
52
|
+
const acct = p.account_name ?? p.account_id;
|
|
53
|
+
truncatedAccount.set(p.id, truncateMiddle(acct, ACCOUNT_CAP));
|
|
54
|
+
if (p.memo)
|
|
55
|
+
truncatedMemo.set(p.id, truncateMiddle(p.memo, MEMO_CAP));
|
|
62
56
|
}
|
|
63
|
-
const accountWidth = Math.max(...
|
|
64
|
-
const amountWidth = Math.max(...
|
|
65
|
-
const side =
|
|
66
|
-
const amt =
|
|
67
|
-
return `${side} ${
|
|
57
|
+
const accountWidth = Math.max(...postings.map((p) => truncatedAccount.get(p.id).length));
|
|
58
|
+
const amountWidth = Math.max(...postings.map((p) => {
|
|
59
|
+
const side = p.debit > 0 ? "DR" : "CR";
|
|
60
|
+
const amt = p.debit > 0 ? p.debit : p.credit;
|
|
61
|
+
return `${side} ${formatAmount(amt)}`.length;
|
|
68
62
|
}));
|
|
69
63
|
const cols = process.stdout.columns || 100;
|
|
70
64
|
const descMax = Math.max(20, cols - 14);
|
|
71
|
-
const groups =
|
|
65
|
+
const groups = groupByTransaction(postings);
|
|
72
66
|
for (const g of groups) {
|
|
73
67
|
const desc = truncateMiddle(g.description, descMax);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
68
|
+
const merchant = g.merchant ? chalk.green(` · ${g.merchant}`) : "";
|
|
69
|
+
console.log(`${chalk.dim(g.date)} ${chalk.bold(desc)}${merchant}`);
|
|
70
|
+
for (const p of g.postings) {
|
|
71
|
+
const acct = truncatedAccount.get(p.id);
|
|
77
72
|
const acctPadded = acct + " ".repeat(accountWidth - acct.length);
|
|
78
|
-
const side =
|
|
79
|
-
const amt =
|
|
80
|
-
const rawAmount = `${side} ${
|
|
81
|
-
const colored =
|
|
73
|
+
const side = p.debit > 0 ? "DR" : "CR";
|
|
74
|
+
const amt = p.debit > 0 ? p.debit : p.credit;
|
|
75
|
+
const rawAmount = `${side} ${formatAmount(amt)}`;
|
|
76
|
+
const colored = p.debit > 0 ? chalk.cyan(rawAmount) : chalk.magenta(rawAmount);
|
|
82
77
|
const amountPadded = " ".repeat(amountWidth - visibleLength(colored)) + colored;
|
|
83
|
-
const memo = truncatedMemo.get(
|
|
78
|
+
const memo = truncatedMemo.get(p.id);
|
|
84
79
|
const memoStr = memo ? ` ${chalk.dim(memo)}` : "";
|
|
85
80
|
console.log(` ${acctPadded} ${amountPadded}${memoStr}`);
|
|
86
81
|
}
|
|
87
82
|
}
|
|
88
83
|
if (groups.length > 1) {
|
|
89
84
|
console.log("");
|
|
90
|
-
console.log(chalk.dim(` ${groups.length}
|
|
85
|
+
console.log(chalk.dim(` ${groups.length} transactions · ${postings.length} postings`));
|
|
91
86
|
}
|
|
92
87
|
}
|
package/dist/cli/format.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
export declare const ANSI_RE: RegExp;
|
|
2
|
+
export declare function visibleLength(s: string): number;
|
|
1
3
|
export declare function formatDuration(ms: number): string;
|
|
2
4
|
export declare function formatError(error: any, context?: string): string;
|
|
3
5
|
export declare function banner(): string;
|
package/dist/cli/format.js
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
+
// eslint-disable-next-line no-control-regex
|
|
3
|
+
export const ANSI_RE = /\x1b\[[0-9;]*m/g;
|
|
4
|
+
export function visibleLength(s) {
|
|
5
|
+
return s.replace(ANSI_RE, "").length;
|
|
6
|
+
}
|
|
2
7
|
export function formatDuration(ms) {
|
|
3
8
|
const s = Math.floor(ms / 1000);
|
|
4
9
|
if (s < 1)
|
|
@@ -49,10 +54,10 @@ export function formatError(error, context) {
|
|
|
49
54
|
return `${chalk.red("✗")} ${context ? context + ": " : ""}${safeMsg}`;
|
|
50
55
|
}
|
|
51
56
|
export function banner() {
|
|
52
|
-
return chalk.bold("Plasalid") + chalk.dim(" ·
|
|
57
|
+
return chalk.bold("Plasalid") + chalk.dim(" · The Harness Layer for Personal Finance");
|
|
53
58
|
}
|
|
54
59
|
function stripAnsi(str) {
|
|
55
|
-
return str.replace(
|
|
60
|
+
return str.replace(ANSI_RE, "");
|
|
56
61
|
}
|
|
57
62
|
function box(label, lines) {
|
|
58
63
|
const cols = process.stdout.columns || 100;
|
package/dist/cli/index.js
CHANGED
|
@@ -15,7 +15,7 @@ function ensureConfigured() {
|
|
|
15
15
|
}
|
|
16
16
|
program
|
|
17
17
|
.name("plasalid")
|
|
18
|
-
.description("The
|
|
18
|
+
.description("The Harness Layer for Personal Finance — local-first")
|
|
19
19
|
.version(version)
|
|
20
20
|
.addHelpCommand(false)
|
|
21
21
|
.showHelpAfterError(`Run ${chalk.cyan("plasalid --help")} for the list of commands.`)
|
|
@@ -62,7 +62,7 @@ program
|
|
|
62
62
|
});
|
|
63
63
|
program
|
|
64
64
|
.command("transactions")
|
|
65
|
-
.description("List
|
|
65
|
+
.description("List transactions and their postings")
|
|
66
66
|
.option("-a, --account <id>", "Filter by account id")
|
|
67
67
|
.option("--from <date>", "From date YYYY-MM-DD")
|
|
68
68
|
.option("--to <date>", "To date YYYY-MM-DD")
|
|
@@ -79,6 +79,14 @@ program
|
|
|
79
79
|
limit: Number(opts.limit),
|
|
80
80
|
});
|
|
81
81
|
});
|
|
82
|
+
program
|
|
83
|
+
.command("record <utterance...>")
|
|
84
|
+
.description("Add a manual entry, account, or balance update from a plain-language line.")
|
|
85
|
+
.action(async (utteranceTokens) => {
|
|
86
|
+
ensureConfigured();
|
|
87
|
+
const { runRecordCommand } = await import("./commands/record.js");
|
|
88
|
+
await runRecordCommand({ utterance: utteranceTokens.join(" ") });
|
|
89
|
+
});
|
|
82
90
|
program
|
|
83
91
|
.command("scan [regex...]")
|
|
84
92
|
.description("Scan every new PDF under ~/.plasalid/data (optionally filtered by regex)")
|
|
@@ -104,7 +112,7 @@ program
|
|
|
104
112
|
});
|
|
105
113
|
program
|
|
106
114
|
.command("review")
|
|
107
|
-
.description("See the whole picture — connect related transactions across statements,
|
|
115
|
+
.description("See the whole picture — connect related transactions across statements, surface recurring patterns, and clear up anything that's still in question.")
|
|
108
116
|
.option("-a, --account <id>", "Limit review to a single account")
|
|
109
117
|
.option("--from <date>", "Only consider entries on or after this date (YYYY-MM-DD)")
|
|
110
118
|
.option("--to <date>", "Only consider entries on or before this date (YYYY-MM-DD)")
|
|
@@ -121,7 +129,7 @@ program
|
|
|
121
129
|
});
|
|
122
130
|
program
|
|
123
131
|
.command("revert <regex>")
|
|
124
|
-
.description("Delete scanned files matching <regex> and all their
|
|
132
|
+
.description("Delete scanned files matching <regex> and all their transactions")
|
|
125
133
|
.action(async (regex) => {
|
|
126
134
|
ensureConfigured();
|
|
127
135
|
const { runRevertCommand } = await import("./commands/revert.js");
|
|
@@ -141,7 +149,11 @@ program.configureHelp({
|
|
|
141
149
|
{ name: "status", desc: "Show net worth and this-month totals" },
|
|
142
150
|
{
|
|
143
151
|
name: "transactions",
|
|
144
|
-
desc: "List
|
|
152
|
+
desc: "List transactions and their postings (filter by account/date/text)",
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
name: "record",
|
|
156
|
+
desc: "Add a manual transaction, account, balance, or merchant from a plain-language line",
|
|
145
157
|
},
|
|
146
158
|
{
|
|
147
159
|
name: "scan",
|
|
@@ -149,11 +161,11 @@ program.configureHelp({
|
|
|
149
161
|
},
|
|
150
162
|
{
|
|
151
163
|
name: "review",
|
|
152
|
-
desc: "
|
|
164
|
+
desc: "Cleanup uncategorized, connect duplicates, learn recurring patterns",
|
|
153
165
|
},
|
|
154
166
|
{
|
|
155
167
|
name: "revert",
|
|
156
|
-
desc: "Delete scanned files matching <regex> and their
|
|
168
|
+
desc: "Delete scanned files matching <regex> and their transactions",
|
|
157
169
|
},
|
|
158
170
|
]),
|
|
159
171
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import chalk from "chalk";
|
|
2
3
|
const HINTS = [
|
|
3
4
|
"try: what is my net worth?",
|
|
4
5
|
"try: how much did I spend on food this month?",
|
|
@@ -33,7 +34,7 @@ export function useFooterText(db) {
|
|
|
33
34
|
scanStr = `scanned ${Math.floor(mins / 1440)}d ago`;
|
|
34
35
|
}
|
|
35
36
|
const idx = (hintIdx + tick) % HINTS.length;
|
|
36
|
-
const parts = ["
|
|
37
|
+
const parts = [`${chalk.cyan("<><")} plasalid`];
|
|
37
38
|
if (scanStr)
|
|
38
39
|
parts.push(scanStr);
|
|
39
40
|
parts.push(HINTS[idx]);
|
|
@@ -41,7 +41,7 @@ export function ScanDashboard({ controller, totalFiles, parallel }) {
|
|
|
41
41
|
break;
|
|
42
42
|
case "scan-end":
|
|
43
43
|
next.set(event.fileName, event.status === "scanned"
|
|
44
|
-
? { kind: "done",
|
|
44
|
+
? { kind: "done", transactions: event.transactions, concerns: event.concerns }
|
|
45
45
|
: { kind: "failed", error: event.error ?? "failed" });
|
|
46
46
|
break;
|
|
47
47
|
}
|
|
@@ -56,7 +56,7 @@ function FileRow({ name, state }) {
|
|
|
56
56
|
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "yellow", children: _jsx(Spinner, { type: "dots" }) }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u00B7 ", state.step] })] }));
|
|
57
57
|
}
|
|
58
58
|
if (state.kind === "done") {
|
|
59
|
-
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "green", children: "\u2713" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["(", state.
|
|
59
|
+
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "green", children: "\u2713" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["(", state.transactions, " transactions, ", state.concerns, " concerns)"] })] }));
|
|
60
60
|
}
|
|
61
61
|
return (_jsxs(Text, { children: [" ", _jsx(Text, { color: "red", children: "\u2717" }), " ", name, " ", _jsxs(Text, { dimColor: true, children: ["\u2014 ", state.error] })] }));
|
|
62
62
|
}
|
package/dist/cli/setup.d.ts
CHANGED
package/dist/cli/setup.js
CHANGED
|
@@ -2,7 +2,7 @@ import chalk from "chalk";
|
|
|
2
2
|
import inquirer from "inquirer";
|
|
3
3
|
import { existsSync, mkdirSync } from "fs";
|
|
4
4
|
import { resolve } from "path";
|
|
5
|
-
import { config, saveConfig, getConfigPath,
|
|
5
|
+
import { config, saveConfig, getConfigPath, getPlasalidDir, getDataDir, } from "../config.js";
|
|
6
6
|
import { generateKey } from "../db/encryption.js";
|
|
7
7
|
import { createContextTemplate } from "../ai/context.js";
|
|
8
8
|
import { printLogo } from "./logo.js";
|
|
@@ -36,7 +36,7 @@ function printSummary(dataDir) {
|
|
|
36
36
|
console.log("Next steps:");
|
|
37
37
|
console.log(` 1. Run ${chalk.cyan("plasalid data")} to drop your bank/credit card statments PDFs in.`);
|
|
38
38
|
console.log(` 2. Run ${chalk.cyan("plasalid scan")} to allow Plasalid to scan them.`);
|
|
39
|
-
console.log(` 3. Run ${chalk.cyan("plasalid")} to
|
|
39
|
+
console.log(` 3. Run ${chalk.cyan("plasalid")} to query your local ledger.`);
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
42
|
* Wraps inquirer's list prompt with a blank line above and below, and inserts
|
|
@@ -202,9 +202,3 @@ export async function runSetup() {
|
|
|
202
202
|
const dataDir = finalizeDataDir(userName || "User");
|
|
203
203
|
printSummary(dataDir);
|
|
204
204
|
}
|
|
205
|
-
export function ensureConfigured() {
|
|
206
|
-
if (!isConfigured()) {
|
|
207
|
-
console.error(chalk.red("Plasalid is not configured. Run `plasalid setup` first."));
|
|
208
|
-
process.exit(1);
|
|
209
|
-
}
|
|
210
|
-
}
|
package/dist/currency.d.ts
CHANGED
|
@@ -3,4 +3,7 @@ export declare function getDisplayCurrency(): string;
|
|
|
3
3
|
export declare function formatCurrencyAmount(amount: number, options?: {
|
|
4
4
|
minimumFractionDigits?: number;
|
|
5
5
|
maximumFractionDigits?: number;
|
|
6
|
+
currency?: string;
|
|
6
7
|
}): string;
|
|
8
|
+
export declare function formatAmount(amount: number, currency?: string): string;
|
|
9
|
+
export declare function formatSignedAmount(amount: number, currency?: string): string;
|
package/dist/currency.js
CHANGED
|
@@ -9,7 +9,7 @@ export function getDisplayCurrency() {
|
|
|
9
9
|
}
|
|
10
10
|
export function formatCurrencyAmount(amount, options = {}) {
|
|
11
11
|
const locale = getDisplayLocale();
|
|
12
|
-
const currency = getDisplayCurrency();
|
|
12
|
+
const currency = options.currency || getDisplayCurrency();
|
|
13
13
|
return new Intl.NumberFormat(locale, {
|
|
14
14
|
style: "currency",
|
|
15
15
|
currency,
|
|
@@ -17,3 +17,14 @@ export function formatCurrencyAmount(amount, options = {}) {
|
|
|
17
17
|
maximumFractionDigits: options.maximumFractionDigits,
|
|
18
18
|
}).format(Math.abs(amount));
|
|
19
19
|
}
|
|
20
|
+
export function formatAmount(amount, currency) {
|
|
21
|
+
return formatCurrencyAmount(amount, {
|
|
22
|
+
minimumFractionDigits: 2,
|
|
23
|
+
maximumFractionDigits: 2,
|
|
24
|
+
currency,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function formatSignedAmount(amount, currency) {
|
|
28
|
+
const body = formatAmount(amount, currency);
|
|
29
|
+
return amount < 0 ? `-${body}` : body;
|
|
30
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type Database from "libsql";
|
|
2
2
|
export type AccountType = "asset" | "liability" | "income" | "expense" | "equity";
|
|
3
|
+
export declare const TOP_LEVEL_TYPES: ReadonlyArray<AccountType>;
|
|
3
4
|
export interface AccountRow {
|
|
4
5
|
id: string;
|
|
5
6
|
name: string;
|
|
6
7
|
type: AccountType;
|
|
8
|
+
parent_id: string | null;
|
|
7
9
|
subtype: string | null;
|
|
8
10
|
bank_name: string | null;
|
|
9
11
|
account_number_masked: string | null;
|
|
@@ -41,13 +43,79 @@ export declare function getPeriodTotals(db: Database.Database, from: string, to:
|
|
|
41
43
|
export declare function findAccountById(db: Database.Database, id: string): AccountRow | null;
|
|
42
44
|
export declare function renameAccount(db: Database.Database, id: string, name: string): number;
|
|
43
45
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
46
|
+
* Idempotently insert one of the five top-level type roots (id = type name,
|
|
47
|
+
* parent_id = null). Called by `createAccount` when a child's declared parent
|
|
48
|
+
* is a missing top-level root.
|
|
49
|
+
*/
|
|
50
|
+
export declare function ensureTopLevelRoot(db: Database.Database, type: AccountType): void;
|
|
51
|
+
/**
|
|
52
|
+
* Idempotently insert one of the structural accounts the system auto-creates:
|
|
53
|
+
* - `expense:uncategorized` (suspense for unclassifiable expense postings)
|
|
54
|
+
* - `equity:adjustments` (balancing side of `adjust_account_balance`)
|
|
55
|
+
* - `equity:opening-balance` (starting state imports)
|
|
56
|
+
* The top-level root is bootstrapped first when missing.
|
|
57
|
+
*/
|
|
58
|
+
export declare function ensureStructuralAccount(db: Database.Database, id: "expense:uncategorized" | "equity:adjustments" | "equity:opening-balance"): void;
|
|
59
|
+
export interface CreateAccountInput {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
type: AccountType;
|
|
63
|
+
parent_id?: string | null;
|
|
64
|
+
subtype?: string | null;
|
|
65
|
+
bank_name?: string | null;
|
|
66
|
+
account_number_masked?: string | null;
|
|
67
|
+
currency?: string;
|
|
68
|
+
due_day?: number | null;
|
|
69
|
+
statement_day?: number | null;
|
|
70
|
+
metadata?: Record<string, unknown> | null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Insert a new account row. Enforces the three hierarchy invariants:
|
|
74
|
+
* 1. Top-level roots: parent_id null, id == type, one of TOP_LEVEL_TYPES.
|
|
75
|
+
* 2. Children: parent_id non-null, parent must exist (the top-level root is
|
|
76
|
+
* auto-bootstrapped if missing — intermediate categories must be created
|
|
77
|
+
* explicitly), parent.type must equal input.type, input.id must start with
|
|
78
|
+
* parent.id + ':'.
|
|
79
|
+
* 3. UNIQUE on id (surfaces as code: 'ACCOUNT_EXISTS').
|
|
80
|
+
*/
|
|
81
|
+
export declare function createAccount(db: Database.Database, input: CreateAccountInput): void;
|
|
82
|
+
export interface UpdateAccountMetadataPatch {
|
|
83
|
+
due_day?: number | null;
|
|
84
|
+
statement_day?: number | null;
|
|
85
|
+
points_balance?: number | null;
|
|
86
|
+
account_number_masked?: string | null;
|
|
87
|
+
bank_name?: string | null;
|
|
88
|
+
metadata?: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
export interface UpdateAccountMetadataResult {
|
|
91
|
+
before: Record<string, unknown>;
|
|
92
|
+
after: Record<string, unknown>;
|
|
93
|
+
changed: boolean;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Patch metadata fields on an account. Returns before/after snapshots of the
|
|
97
|
+
* touched fields so callers can persist a reversible audit record. `metadata`
|
|
98
|
+
* is shallow-merged into the existing metadata_json blob.
|
|
99
|
+
*/
|
|
100
|
+
export declare function updateAccountMetadata(db: Database.Database, id: string, patch: UpdateAccountMetadataPatch): UpdateAccountMetadataResult;
|
|
101
|
+
/**
|
|
102
|
+
* Re-point every posting on `fromId` to `toId`, then delete the source account.
|
|
103
|
+
* Wrapped in a transaction. Refuses if the source still has children. Returns
|
|
104
|
+
* the number of postings moved.
|
|
47
105
|
*/
|
|
48
106
|
export declare function mergeAccounts(db: Database.Database, fromId: string, toId: string): number;
|
|
49
|
-
/** Delete an account only if no
|
|
107
|
+
/** Delete an account only if no postings reference it AND it has no children. */
|
|
50
108
|
export declare function deleteAccount(db: Database.Database, id: string): void;
|
|
109
|
+
/**
|
|
110
|
+
* Recursive CTE walk over `accounts.parent_id` returning the root and every
|
|
111
|
+
* descendant. Used by `getRollupBalance` and by hierarchical rendering paths.
|
|
112
|
+
*/
|
|
113
|
+
export declare function getAccountSubtree(db: Database.Database, rootId: string): AccountRow[];
|
|
114
|
+
/**
|
|
115
|
+
* Sum the natural balance of every account in a subtree (root inclusive).
|
|
116
|
+
* Uses the same debit-normal / credit-normal convention as `getAccountBalances`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function getRollupBalance(db: Database.Database, rootId: string): number;
|
|
51
119
|
export interface SimilarAccountPair {
|
|
52
120
|
a: AccountRow;
|
|
53
121
|
b: AccountRow;
|
|
@@ -60,3 +128,14 @@ export interface SimilarAccountPair {
|
|
|
60
128
|
*/
|
|
61
129
|
export declare function findSimilarAccounts(db: Database.Database, threshold?: number): SimilarAccountPair[];
|
|
62
130
|
export declare function findUnusedAccounts(db: Database.Database): AccountRow[];
|
|
131
|
+
export interface FuzzyAccountMatch {
|
|
132
|
+
account: AccountRow;
|
|
133
|
+
similarity: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Rank the chart of accounts by name similarity to a free-text query. Returns
|
|
137
|
+
* matches at or above `threshold`, highest first. Bonus weight when the query
|
|
138
|
+
* is a substring of the name so "ttb saving" still finds "TTB Savings ••1234"
|
|
139
|
+
* even though pure Levenshtein on the full strings is mediocre.
|
|
140
|
+
*/
|
|
141
|
+
export declare function findAccountsByFuzzyName(db: Database.Database, query: string, threshold?: number): FuzzyAccountMatch[];
|