plasalid 0.4.1 → 0.5.0

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 (65) hide show
  1. package/README.md +15 -14
  2. package/dist/ai/agent.d.ts +15 -2
  3. package/dist/ai/agent.js +21 -2
  4. package/dist/ai/memory.d.ts +2 -0
  5. package/dist/ai/memory.js +2 -2
  6. package/dist/ai/personas.d.ts +2 -1
  7. package/dist/ai/personas.js +115 -45
  8. package/dist/ai/prompt-sections.d.ts +5 -0
  9. package/dist/ai/prompt-sections.js +26 -8
  10. package/dist/ai/system-prompt.d.ts +11 -0
  11. package/dist/ai/system-prompt.js +21 -6
  12. package/dist/ai/thinking.js +1 -1
  13. package/dist/ai/tools/common.js +2 -5
  14. package/dist/ai/tools/index.js +28 -8
  15. package/dist/ai/tools/ingest.d.ts +2 -1
  16. package/dist/ai/tools/ingest.js +262 -151
  17. package/dist/ai/tools/merchants.d.ts +2 -0
  18. package/dist/ai/tools/merchants.js +117 -0
  19. package/dist/ai/tools/read.js +31 -29
  20. package/dist/ai/tools/record.d.ts +2 -0
  21. package/dist/ai/tools/record.js +188 -0
  22. package/dist/ai/tools/review.js +77 -80
  23. package/dist/ai/tools/scan.js +1 -1
  24. package/dist/ai/tools/types.d.ts +15 -6
  25. package/dist/cli/commands/accounts.js +33 -25
  26. package/dist/cli/commands/record.d.ts +4 -0
  27. package/dist/cli/commands/record.js +119 -0
  28. package/dist/cli/commands/revert.js +1 -1
  29. package/dist/cli/commands/scan.js +15 -19
  30. package/dist/cli/commands/status.js +6 -9
  31. package/dist/cli/commands/transactions.js +36 -41
  32. package/dist/cli/format.d.ts +2 -0
  33. package/dist/cli/format.js +7 -2
  34. package/dist/cli/index.js +19 -7
  35. package/dist/cli/ink/scan_dashboard.d.ts +1 -1
  36. package/dist/cli/ink/scan_dashboard.js +2 -2
  37. package/dist/cli/setup.d.ts +0 -1
  38. package/dist/cli/setup.js +2 -8
  39. package/dist/currency.d.ts +3 -0
  40. package/dist/currency.js +12 -1
  41. package/dist/db/queries/account_balance.d.ts +83 -4
  42. package/dist/db/queries/account_balance.js +239 -20
  43. package/dist/db/queries/action_log.d.ts +29 -0
  44. package/dist/db/queries/action_log.js +27 -0
  45. package/dist/db/queries/concerns.d.ts +10 -7
  46. package/dist/db/queries/concerns.js +20 -16
  47. package/dist/db/queries/journal.d.ts +1 -0
  48. package/dist/db/queries/merchants.d.ts +42 -0
  49. package/dist/db/queries/merchants.js +120 -0
  50. package/dist/db/queries/recurrences.d.ts +3 -3
  51. package/dist/db/queries/recurrences.js +32 -34
  52. package/dist/db/queries/search.d.ts +5 -4
  53. package/dist/db/queries/search.js +16 -12
  54. package/dist/db/queries/transactions.d.ts +167 -0
  55. package/dist/db/queries/transactions.js +320 -0
  56. package/dist/db/schema.js +51 -9
  57. package/dist/reviewer/pipeline.d.ts +4 -4
  58. package/dist/reviewer/pipeline.js +4 -4
  59. package/dist/reviewer/prompts.js +4 -4
  60. package/dist/scanner/buffer.d.ts +24 -21
  61. package/dist/scanner/buffer.js +18 -18
  62. package/dist/scanner/pipeline.d.ts +3 -2
  63. package/dist/scanner/pipeline.js +33 -36
  64. package/dist/scanner/prompts.js +3 -3
  65. package/package.json +2 -2
@@ -22,7 +22,13 @@ export async function runScanCommand(opts) {
22
22
  });
23
23
  renderScanSummary(summary);
24
24
  }
25
- // ── Ink-based events (TTY mode) ────────────────────────────────────────────
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: (e) => {
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
- entries: e.entries,
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
- // ── Plain-text progress (TTY or piped, no ink yet) ─────────────────────────
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: (e) => {
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.entries} entries, ${e.concerns} concerns)`)}`);
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
- // ── Terse summary ──────────────────────────────────────────────────────────
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.entries} entries${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""}`);
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.entries} entries${d.concerns > 0 ? ` · ${d.concerns} concerns` : ""} (replaces prior)`);
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 { formatCurrencyAmount } from "../../currency.js";
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: ") + fmt(nw.net_worth));
12
- console.log(chalk.dim(`Assets ${fmt(nw.assets)} − Liabilities ${fmt(nw.liabilities)}`));
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: ${fmt(totals.income)}`);
20
- console.log(` Expenses: ${fmt(totals.expenses)}`);
21
- console.log(` Net: ${fmt(totals.income - totals.expenses)}`);
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 { listJournalLines } from "../../db/queries/journal.js";
4
- import { formatCurrencyAmount } from "../../currency.js";
5
- function fmt(n) {
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 groupByEntry(lines) {
18
+ function groupByTransaction(postings) {
26
19
  const groups = [];
27
20
  let current = null;
28
- for (const l of lines) {
29
- if (!current || current.entry_id !== l.entry_id) {
21
+ for (const p of postings) {
22
+ if (!current || current.transaction_id !== p.transaction_id) {
30
23
  current = {
31
- entry_id: l.entry_id,
32
- date: l.entry_date ?? "",
33
- description: l.entry_description ?? "",
34
- lines: [],
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.lines.push(l);
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 lines = listJournalLines(db, {
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 (lines.length === 0) {
52
- console.log(chalk.yellow("No journal lines match those filters."));
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 l of lines) {
58
- const acct = l.account_name ?? l.account_id;
59
- truncatedAccount.set(l.id, truncateMiddle(acct, ACCOUNT_CAP));
60
- if (l.memo)
61
- truncatedMemo.set(l.id, truncateMiddle(l.memo, MEMO_CAP));
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(...lines.map((l) => truncatedAccount.get(l.id).length));
64
- const amountWidth = Math.max(...lines.map((l) => {
65
- const side = l.debit > 0 ? "DR" : "CR";
66
- const amt = l.debit > 0 ? l.debit : l.credit;
67
- return `${side} ${fmt(amt)}`.length;
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 = groupByEntry(lines);
65
+ const groups = groupByTransaction(postings);
72
66
  for (const g of groups) {
73
67
  const desc = truncateMiddle(g.description, descMax);
74
- console.log(`${chalk.dim(g.date)} ${chalk.bold(desc)}`);
75
- for (const l of g.lines) {
76
- const acct = truncatedAccount.get(l.id);
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 = l.debit > 0 ? "DR" : "CR";
79
- const amt = l.debit > 0 ? l.debit : l.credit;
80
- const rawAmount = `${side} ${fmt(amt)}`;
81
- const colored = l.debit > 0 ? chalk.cyan(rawAmount) : chalk.magenta(rawAmount);
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(l.id);
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} entries · ${lines.length} lines`));
85
+ console.log(chalk.dim(` ${groups.length} transactions · ${postings.length} postings`));
91
86
  }
92
87
  }
@@ -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;
@@ -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(" · Talk to your money");
57
+ return chalk.bold("Plasalid") + chalk.dim(" · The Harness Layer for Personal Finance");
53
58
  }
54
59
  function stripAnsi(str) {
55
- return str.replace(/\x1b\[[0-9;]*m/g, "");
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 local-first data layer for personal finance")
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 journal lines")
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, learn the rhythm of your recurring money, and clear up anything that's still in question.")
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 journal entries")
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 journal lines (filter by account/date/text)",
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: "Connect the dots and learn your recurring rhythms",
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 journal entries",
168
+ desc: "Delete scanned files matching <regex> and their transactions",
157
169
  },
158
170
  ]),
159
171
  });
@@ -9,7 +9,7 @@ export type ScanDashboardEvent = {
9
9
  type: "scan-end";
10
10
  fileName: string;
11
11
  status: "scanned" | "failed";
12
- entries: number;
12
+ transactions: number;
13
13
  concerns: number;
14
14
  error?: string;
15
15
  };
@@ -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", entries: event.entries, concerns: event.concerns }
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.entries, " entries, ", state.concerns, " concerns)"] })] }));
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
  }
@@ -1,2 +1 @@
1
1
  export declare function runSetup(): Promise<void>;
2
- export declare function ensureConfigured(): void;
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, isConfigured, getPlasalidDir, getDataDir, } from "../config.js";
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 chat with your financial data.`);
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
- }
@@ -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
- * Re-point every journal line on `fromId` to `toId`, then delete the source
45
- * account. Wrapped in a transaction. Returns the number of journal lines moved.
46
- * Throws if either account doesn't exist.
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 journal_lines reference it. */
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[];