plasalid 0.3.5 → 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 (81) hide show
  1. package/README.md +33 -43
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +19 -5
  5. package/dist/ai/agent.js +26 -6
  6. package/dist/ai/memory.d.ts +14 -5
  7. package/dist/ai/memory.js +12 -0
  8. package/dist/ai/personas.d.ts +11 -0
  9. package/dist/ai/personas.js +193 -0
  10. package/dist/ai/prompt-sections.d.ts +49 -0
  11. package/dist/ai/prompt-sections.js +107 -0
  12. package/dist/ai/system-prompt.d.ts +14 -3
  13. package/dist/ai/system-prompt.js +59 -165
  14. package/dist/ai/thinking.js +1 -1
  15. package/dist/ai/tools/common.js +2 -5
  16. package/dist/ai/tools/index.js +32 -7
  17. package/dist/ai/tools/ingest.d.ts +3 -1
  18. package/dist/ai/tools/ingest.js +372 -124
  19. package/dist/ai/tools/merchants.d.ts +2 -0
  20. package/dist/ai/tools/merchants.js +117 -0
  21. package/dist/ai/tools/read.js +57 -24
  22. package/dist/ai/tools/record.d.ts +2 -0
  23. package/dist/ai/tools/record.js +188 -0
  24. package/dist/ai/tools/review.d.ts +2 -0
  25. package/dist/ai/tools/review.js +359 -0
  26. package/dist/ai/tools/scan.js +5 -3
  27. package/dist/ai/tools/types.d.ts +33 -4
  28. package/dist/cli/commands/accounts.js +33 -25
  29. package/dist/cli/commands/record.d.ts +4 -0
  30. package/dist/cli/commands/record.js +119 -0
  31. package/dist/cli/commands/revert.js +1 -1
  32. package/dist/cli/commands/review.d.ts +2 -0
  33. package/dist/cli/commands/review.js +15 -0
  34. package/dist/cli/commands/scan.d.ts +4 -2
  35. package/dist/cli/commands/scan.js +143 -19
  36. package/dist/cli/commands/status.js +6 -9
  37. package/dist/cli/commands/transactions.js +36 -41
  38. package/dist/cli/format.d.ts +2 -0
  39. package/dist/cli/format.js +7 -2
  40. package/dist/cli/index.js +28 -13
  41. package/dist/cli/ink/scan_dashboard.d.ts +38 -0
  42. package/dist/cli/ink/scan_dashboard.js +62 -0
  43. package/dist/cli/setup.d.ts +0 -1
  44. package/dist/cli/setup.js +2 -8
  45. package/dist/cli/ux.d.ts +2 -1
  46. package/dist/cli/ux.js +36 -2
  47. package/dist/currency.d.ts +3 -0
  48. package/dist/currency.js +12 -1
  49. package/dist/db/queries/account_balance.d.ts +84 -4
  50. package/dist/db/queries/account_balance.js +239 -20
  51. package/dist/db/queries/action_log.d.ts +29 -0
  52. package/dist/db/queries/action_log.js +27 -0
  53. package/dist/db/queries/concerns.d.ts +50 -0
  54. package/dist/db/queries/concerns.js +91 -0
  55. package/dist/db/queries/journal.d.ts +75 -8
  56. package/dist/db/queries/journal.js +131 -19
  57. package/dist/db/queries/merchants.d.ts +42 -0
  58. package/dist/db/queries/merchants.js +120 -0
  59. package/dist/db/queries/recurrences.d.ts +33 -0
  60. package/dist/db/queries/recurrences.js +128 -0
  61. package/dist/db/queries/search.d.ts +5 -4
  62. package/dist/db/queries/search.js +16 -12
  63. package/dist/db/queries/transactions.d.ts +167 -0
  64. package/dist/db/queries/transactions.js +320 -0
  65. package/dist/db/schema.js +74 -9
  66. package/dist/reviewer/pipeline.d.ts +18 -0
  67. package/dist/reviewer/pipeline.js +46 -0
  68. package/dist/reviewer/prompts.d.ts +12 -0
  69. package/dist/reviewer/prompts.js +22 -0
  70. package/dist/scanner/account_mutex.d.ts +1 -0
  71. package/dist/scanner/account_mutex.js +16 -0
  72. package/dist/scanner/buffer.d.ts +51 -0
  73. package/dist/scanner/buffer.js +63 -0
  74. package/dist/scanner/concurrency.d.ts +14 -0
  75. package/dist/scanner/concurrency.js +31 -0
  76. package/dist/scanner/decrypt_queue.d.ts +57 -0
  77. package/dist/scanner/decrypt_queue.js +96 -0
  78. package/dist/scanner/pipeline.d.ts +47 -18
  79. package/dist/scanner/pipeline.js +247 -97
  80. package/dist/scanner/prompts.js +3 -3
  81. package/package.json +2 -2
package/dist/cli/ux.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import inquirer from "inquirer";
2
2
  import ora from "ora";
3
+ import chalk from "chalk";
3
4
  import { TOOL_LABELS } from "../ai/tools/index.js";
4
5
  import { pickThinking } from "../ai/thinking.js";
5
6
  import { formatDuration } from "./format.js";
@@ -45,9 +46,12 @@ export function statusSpinner(text) {
45
46
  */
46
47
  export function makePromptUser(spinner) {
47
48
  const OTHER = "__plasalid_other__";
48
- return async (prompt, options) => {
49
+ return async (prompt, options, facts) => {
49
50
  spinner.pause();
50
51
  console.log("");
52
+ const factsLine = facts ? formatFacts(facts) : null;
53
+ if (factsLine)
54
+ console.log(factsLine);
51
55
  try {
52
56
  if (options && options.length > 0) {
53
57
  const choices = [
@@ -60,7 +64,18 @@ export function makePromptUser(spinner) {
60
64
  { name: "Type a different answer…", value: OTHER },
61
65
  ];
62
66
  const { choice } = await inquirer.prompt([
63
- { type: "list", name: "choice", message: prompt, choices },
67
+ {
68
+ type: "list",
69
+ name: "choice",
70
+ message: prompt,
71
+ choices,
72
+ // Stop the cursor at the top/bottom instead of wrapping forever —
73
+ // the wrap-around default makes the list feel infinite.
74
+ loop: false,
75
+ // Show every choice without paginating. Floor of 10 keeps the
76
+ // prompt height predictable for small option sets.
77
+ pageSize: Math.max(choices.length, 10),
78
+ },
64
79
  ]);
65
80
  if (choice === OTHER) {
66
81
  const { freeform } = await inquirer.prompt([
@@ -102,3 +117,22 @@ export function makeAgentOnProgress(spinner, subject) {
102
117
  }
103
118
  };
104
119
  }
120
+ /**
121
+ * Render the structured facts the review agent attaches to ask_user as a
122
+ * single colored line above the inquirer prompt. Each category has a fixed
123
+ * chalk color so the user's eye picks out the type without reading prose.
124
+ * Returns null when there's nothing to render (so the caller can skip the
125
+ * blank line entirely).
126
+ */
127
+ function formatFacts(f) {
128
+ const parts = [];
129
+ if (f.amount)
130
+ parts.push(chalk.yellow(f.amount));
131
+ if (f.date)
132
+ parts.push(chalk.cyan(f.date));
133
+ if (f.merchant)
134
+ parts.push(chalk.green(f.merchant));
135
+ for (const a of f.accounts ?? [])
136
+ parts.push(chalk.magenta(a));
137
+ return parts.length ? parts.join(chalk.dim(" · ")) : null;
138
+ }
@@ -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;
@@ -13,6 +15,7 @@ export interface AccountRow {
13
15
  points_balance: number | null;
14
16
  metadata_json: string | null;
15
17
  pii_flag: number;
18
+ has_concern: number;
16
19
  created_at: string;
17
20
  }
18
21
  export interface AccountBalance extends AccountRow {
@@ -40,13 +43,79 @@ export declare function getPeriodTotals(db: Database.Database, from: string, to:
40
43
  export declare function findAccountById(db: Database.Database, id: string): AccountRow | null;
41
44
  export declare function renameAccount(db: Database.Database, id: string, name: string): number;
42
45
  /**
43
- * Re-point every journal line on `fromId` to `toId`, then delete the source
44
- * account. Wrapped in a transaction. Returns the number of journal lines moved.
45
- * 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.
46
105
  */
47
106
  export declare function mergeAccounts(db: Database.Database, fromId: string, toId: string): number;
48
- /** 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. */
49
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;
50
119
  export interface SimilarAccountPair {
51
120
  a: AccountRow;
52
121
  b: AccountRow;
@@ -59,3 +128,14 @@ export interface SimilarAccountPair {
59
128
  */
60
129
  export declare function findSimilarAccounts(db: Database.Database, threshold?: number): SimilarAccountPair[];
61
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[];
@@ -1,3 +1,13 @@
1
+ export const TOP_LEVEL_TYPES = [
2
+ "asset", "liability", "income", "expense", "equity",
3
+ ];
4
+ const TYPE_ROOT_NAME = {
5
+ asset: "Assets",
6
+ liability: "Liabilities",
7
+ income: "Income",
8
+ expense: "Expenses",
9
+ equity: "Equity",
10
+ };
1
11
  /**
2
12
  * Balance per account using the natural debit/credit convention:
3
13
  * asset / expense → debit-normal → balance = debits − credits
@@ -12,13 +22,13 @@ export function getAccountBalances(db, opts = {}) {
12
22
  }
13
23
  const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
14
24
  const rows = db.prepare(`SELECT a.*,
15
- COALESCE(SUM(jl.debit), 0) AS sum_debit,
16
- COALESCE(SUM(jl.credit), 0) AS sum_credit
25
+ COALESCE(SUM(p.debit), 0) AS sum_debit,
26
+ COALESCE(SUM(p.credit), 0) AS sum_credit
17
27
  FROM accounts a
18
- LEFT JOIN journal_lines jl ON jl.account_id = a.id
28
+ LEFT JOIN postings p ON p.account_id = a.id
19
29
  ${whereSql}
20
30
  GROUP BY a.id
21
- ORDER BY a.type, a.name`).all(...params);
31
+ ORDER BY a.type, a.id`).all(...params);
22
32
  return rows.map(r => {
23
33
  const debitNormal = r.type === "asset" || r.type === "expense";
24
34
  const balance = debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
@@ -40,12 +50,12 @@ export function getNetWorth(db) {
40
50
  }
41
51
  export function getPeriodTotals(db, from, to) {
42
52
  const row = db.prepare(`SELECT
43
- COALESCE(SUM(CASE WHEN a.type = 'income' THEN jl.credit - jl.debit ELSE 0 END), 0) AS income,
44
- COALESCE(SUM(CASE WHEN a.type = 'expense' THEN jl.debit - jl.credit ELSE 0 END), 0) AS expenses
45
- FROM journal_lines jl
46
- JOIN journal_entries je ON je.id = jl.entry_id
47
- JOIN accounts a ON a.id = jl.account_id
48
- WHERE je.date BETWEEN ? AND ?`).get(from, to);
53
+ COALESCE(SUM(CASE WHEN a.type = 'income' THEN p.credit - p.debit ELSE 0 END), 0) AS income,
54
+ COALESCE(SUM(CASE WHEN a.type = 'expense' THEN p.debit - p.credit ELSE 0 END), 0) AS expenses
55
+ FROM postings p
56
+ JOIN transactions t ON t.id = p.transaction_id
57
+ JOIN accounts a ON a.id = p.account_id
58
+ WHERE t.date BETWEEN ? AND ?`).get(from, to);
49
59
  return { income: row.income, expenses: row.expenses };
50
60
  }
51
61
  export function findAccountById(db, id) {
@@ -55,9 +65,145 @@ export function renameAccount(db, id, name) {
55
65
  return db.prepare(`UPDATE accounts SET name = ? WHERE id = ?`).run(name, id).changes;
56
66
  }
57
67
  /**
58
- * Re-point every journal line on `fromId` to `toId`, then delete the source
59
- * account. Wrapped in a transaction. Returns the number of journal lines moved.
60
- * Throws if either account doesn't exist.
68
+ * Idempotently insert one of the five top-level type roots (id = type name,
69
+ * parent_id = null). Called by `createAccount` when a child's declared parent
70
+ * is a missing top-level root.
71
+ */
72
+ export function ensureTopLevelRoot(db, type) {
73
+ if (findAccountById(db, type))
74
+ return;
75
+ db.prepare(`INSERT INTO accounts (id, name, type, parent_id) VALUES (?, ?, ?, NULL)`).run(type, TYPE_ROOT_NAME[type], type);
76
+ }
77
+ /**
78
+ * Idempotently insert one of the structural accounts the system auto-creates:
79
+ * - `expense:uncategorized` (suspense for unclassifiable expense postings)
80
+ * - `equity:adjustments` (balancing side of `adjust_account_balance`)
81
+ * - `equity:opening-balance` (starting state imports)
82
+ * The top-level root is bootstrapped first when missing.
83
+ */
84
+ export function ensureStructuralAccount(db, id) {
85
+ if (findAccountById(db, id))
86
+ return;
87
+ const [type, leaf] = id.split(":");
88
+ ensureTopLevelRoot(db, type);
89
+ const name = leaf === "uncategorized" ? "Uncategorized"
90
+ : leaf === "adjustments" ? "Adjustments"
91
+ : "Opening Balance";
92
+ db.prepare(`INSERT INTO accounts (id, name, type, parent_id) VALUES (?, ?, ?, ?)`).run(id, name, type, type);
93
+ }
94
+ /**
95
+ * Insert a new account row. Enforces the three hierarchy invariants:
96
+ * 1. Top-level roots: parent_id null, id == type, one of TOP_LEVEL_TYPES.
97
+ * 2. Children: parent_id non-null, parent must exist (the top-level root is
98
+ * auto-bootstrapped if missing — intermediate categories must be created
99
+ * explicitly), parent.type must equal input.type, input.id must start with
100
+ * parent.id + ':'.
101
+ * 3. UNIQUE on id (surfaces as code: 'ACCOUNT_EXISTS').
102
+ */
103
+ export function createAccount(db, input) {
104
+ const bank = input.bank_name ? String(input.bank_name).toUpperCase() : null;
105
+ const parentId = input.parent_id ?? null;
106
+ if (parentId === null) {
107
+ if (!TOP_LEVEL_TYPES.includes(input.id)) {
108
+ throw new Error(`Account "${input.id}" has no parent_id; only top-level type roots may have a null parent (one of ${TOP_LEVEL_TYPES.join(", ")}).`);
109
+ }
110
+ if (input.id !== input.type) {
111
+ throw new Error(`Top-level root id "${input.id}" must equal its type "${input.type}".`);
112
+ }
113
+ }
114
+ else {
115
+ let parent = findAccountById(db, parentId);
116
+ if (!parent) {
117
+ if (TOP_LEVEL_TYPES.includes(parentId)) {
118
+ ensureTopLevelRoot(db, parentId);
119
+ parent = findAccountById(db, parentId);
120
+ }
121
+ }
122
+ if (!parent) {
123
+ throw new Error(`Parent account "${parentId}" does not exist; create it first.`);
124
+ }
125
+ if (parent.type !== input.type) {
126
+ throw new Error(`Account "${input.id}" type "${input.type}" does not match parent "${parentId}" type "${parent.type}".`);
127
+ }
128
+ if (!input.id.startsWith(parent.id + ":")) {
129
+ throw new Error(`Account id "${input.id}" must start with parent id "${parent.id}:".`);
130
+ }
131
+ }
132
+ try {
133
+ db.prepare(`INSERT INTO accounts (id, name, type, parent_id, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
134
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.name, input.type, parentId, input.subtype ?? null, bank, input.account_number_masked ?? null, input.currency ?? "THB", input.due_day ?? null, input.statement_day ?? null, input.metadata ? JSON.stringify(input.metadata) : null);
135
+ }
136
+ catch (err) {
137
+ if (String(err.message).includes("UNIQUE")) {
138
+ const dup = new Error(`Account "${input.id}" already exists.`);
139
+ dup.code = "ACCOUNT_EXISTS";
140
+ throw dup;
141
+ }
142
+ throw err;
143
+ }
144
+ }
145
+ /**
146
+ * Patch metadata fields on an account. Returns before/after snapshots of the
147
+ * touched fields so callers can persist a reversible audit record. `metadata`
148
+ * is shallow-merged into the existing metadata_json blob.
149
+ */
150
+ export function updateAccountMetadata(db, id, patch) {
151
+ const current = findAccountById(db, id);
152
+ if (!current)
153
+ throw new Error(`Account "${id}" not found.`);
154
+ const sets = [];
155
+ const params = [];
156
+ const before = {};
157
+ const after = {};
158
+ if (patch.due_day !== undefined) {
159
+ sets.push("due_day = ?");
160
+ params.push(patch.due_day);
161
+ before.due_day = current.due_day;
162
+ after.due_day = patch.due_day;
163
+ }
164
+ if (patch.statement_day !== undefined) {
165
+ sets.push("statement_day = ?");
166
+ params.push(patch.statement_day);
167
+ before.statement_day = current.statement_day;
168
+ after.statement_day = patch.statement_day;
169
+ }
170
+ if (patch.points_balance !== undefined) {
171
+ sets.push("points_balance = ?");
172
+ params.push(patch.points_balance);
173
+ before.points_balance = current.points_balance;
174
+ after.points_balance = patch.points_balance;
175
+ }
176
+ if (patch.account_number_masked !== undefined) {
177
+ sets.push("account_number_masked = ?");
178
+ params.push(patch.account_number_masked);
179
+ before.account_number_masked = current.account_number_masked;
180
+ after.account_number_masked = patch.account_number_masked;
181
+ }
182
+ if (patch.bank_name !== undefined) {
183
+ const next = patch.bank_name == null ? null : String(patch.bank_name).toUpperCase();
184
+ sets.push("bank_name = ?");
185
+ params.push(next);
186
+ before.bank_name = current.bank_name;
187
+ after.bank_name = next;
188
+ }
189
+ if (patch.metadata) {
190
+ const existing = current.metadata_json ? JSON.parse(current.metadata_json) : {};
191
+ const merged = { ...existing, ...patch.metadata };
192
+ sets.push("metadata_json = ?");
193
+ params.push(JSON.stringify(merged));
194
+ before.metadata = existing;
195
+ after.metadata = merged;
196
+ }
197
+ if (sets.length === 0)
198
+ return { before, after, changed: false };
199
+ params.push(id);
200
+ db.prepare(`UPDATE accounts SET ${sets.join(", ")} WHERE id = ?`).run(...params);
201
+ return { before, after, changed: true };
202
+ }
203
+ /**
204
+ * Re-point every posting on `fromId` to `toId`, then delete the source account.
205
+ * Wrapped in a transaction. Refuses if the source still has children. Returns
206
+ * the number of postings moved.
61
207
  */
62
208
  export function mergeAccounts(db, fromId, toId) {
63
209
  if (fromId === toId)
@@ -68,26 +214,74 @@ export function mergeAccounts(db, fromId, toId) {
68
214
  const to = findAccountById(db, toId);
69
215
  if (!to)
70
216
  throw new Error(`Destination account ${toId} not found.`);
217
+ const childCount = db
218
+ .prepare(`SELECT COUNT(*) AS n FROM accounts WHERE parent_id = ?`)
219
+ .get(fromId);
220
+ if (childCount.n > 0) {
221
+ throw new Error(`Account ${fromId} has ${childCount.n} child account(s); merge or delete them first.`);
222
+ }
71
223
  let moved = 0;
72
224
  const tx = db.transaction(() => {
73
225
  moved = db
74
- .prepare(`UPDATE journal_lines SET account_id = ? WHERE account_id = ?`)
226
+ .prepare(`UPDATE postings SET account_id = ? WHERE account_id = ?`)
75
227
  .run(toId, fromId).changes;
76
228
  db.prepare(`DELETE FROM accounts WHERE id = ?`).run(fromId);
77
229
  });
78
230
  tx();
79
231
  return moved;
80
232
  }
81
- /** Delete an account only if no journal_lines reference it. */
233
+ /** Delete an account only if no postings reference it AND it has no children. */
82
234
  export function deleteAccount(db, id) {
83
235
  const inUse = db
84
- .prepare(`SELECT 1 FROM journal_lines WHERE account_id = ? LIMIT 1`)
236
+ .prepare(`SELECT 1 FROM postings WHERE account_id = ? LIMIT 1`)
85
237
  .get(id);
86
238
  if (inUse) {
87
- throw new Error(`Account ${id} still has journal lines; merge it first.`);
239
+ throw new Error(`Account ${id} still has postings; merge it first.`);
240
+ }
241
+ const childCount = db
242
+ .prepare(`SELECT COUNT(*) AS n FROM accounts WHERE parent_id = ?`)
243
+ .get(id);
244
+ if (childCount.n > 0) {
245
+ throw new Error(`Account ${id} has ${childCount.n} child account(s); delete them first.`);
88
246
  }
89
247
  db.prepare(`DELETE FROM accounts WHERE id = ?`).run(id);
90
248
  }
249
+ /**
250
+ * Recursive CTE walk over `accounts.parent_id` returning the root and every
251
+ * descendant. Used by `getRollupBalance` and by hierarchical rendering paths.
252
+ */
253
+ export function getAccountSubtree(db, rootId) {
254
+ return db.prepare(`WITH RECURSIVE subtree AS (
255
+ SELECT * FROM accounts WHERE id = ?
256
+ UNION ALL
257
+ SELECT a.* FROM accounts a JOIN subtree s ON a.parent_id = s.id
258
+ )
259
+ SELECT * FROM subtree ORDER BY id`).all(rootId);
260
+ }
261
+ /**
262
+ * Sum the natural balance of every account in a subtree (root inclusive).
263
+ * Uses the same debit-normal / credit-normal convention as `getAccountBalances`.
264
+ */
265
+ export function getRollupBalance(db, rootId) {
266
+ const subtree = getAccountSubtree(db, rootId);
267
+ if (subtree.length === 0)
268
+ return 0;
269
+ const ids = subtree.map(a => a.id);
270
+ const placeholders = ids.map(() => "?").join(",");
271
+ const row = db.prepare(`SELECT a.type,
272
+ COALESCE(SUM(p.debit), 0) AS sum_debit,
273
+ COALESCE(SUM(p.credit), 0) AS sum_credit
274
+ FROM accounts a
275
+ LEFT JOIN postings p ON p.account_id = a.id
276
+ WHERE a.id IN (${placeholders})
277
+ GROUP BY a.type`).all(...ids);
278
+ let total = 0;
279
+ for (const r of row) {
280
+ const debitNormal = r.type === "asset" || r.type === "expense";
281
+ total += debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
282
+ }
283
+ return total;
284
+ }
91
285
  /**
92
286
  * Pairwise Levenshtein similarity over `accounts.name`. Returns pairs above the
93
287
  * threshold (0–1, where 1 = identical), sorted highest first. Quadratic in the
@@ -109,12 +303,37 @@ export function findSimilarAccounts(db, threshold = 0.85) {
109
303
  export function findUnusedAccounts(db) {
110
304
  return db
111
305
  .prepare(`SELECT a.* FROM accounts a
112
- LEFT JOIN journal_lines jl ON jl.account_id = a.id
113
- WHERE jl.id IS NULL
306
+ LEFT JOIN postings p ON p.account_id = a.id
307
+ WHERE p.id IS NULL
308
+ AND NOT EXISTS (SELECT 1 FROM accounts c WHERE c.parent_id = a.id)
309
+ AND a.id NOT IN ('asset','liability','income','expense','equity')
114
310
  ORDER BY a.name`)
115
311
  .all();
116
312
  }
117
- // ── helpers ────────────────────────────────────────────────────────────────
313
+ /**
314
+ * Rank the chart of accounts by name similarity to a free-text query. Returns
315
+ * matches at or above `threshold`, highest first. Bonus weight when the query
316
+ * is a substring of the name so "ttb saving" still finds "TTB Savings ••1234"
317
+ * even though pure Levenshtein on the full strings is mediocre.
318
+ */
319
+ export function findAccountsByFuzzyName(db, query, threshold = 0.5) {
320
+ const q = query.trim().toLowerCase();
321
+ if (!q)
322
+ return [];
323
+ const rows = db.prepare(`SELECT * FROM accounts ORDER BY name`).all();
324
+ const out = [];
325
+ for (const row of rows) {
326
+ const name = row.name.toLowerCase();
327
+ let score = similarity(q, name);
328
+ if (name.includes(q) || q.includes(name))
329
+ score = Math.max(score, 0.85);
330
+ if (score >= threshold) {
331
+ out.push({ account: row, similarity: Math.round(score * 1000) / 1000 });
332
+ }
333
+ }
334
+ out.sort((a, b) => b.similarity - a.similarity);
335
+ return out;
336
+ }
118
337
  function similarity(a, b) {
119
338
  if (a === b)
120
339
  return 1;
@@ -0,0 +1,29 @@
1
+ import type Database from "libsql";
2
+ export type ActionCommand = "record" | "scan" | "review";
3
+ export type ActionType = "create_account" | "update_account_metadata" | "record_transaction" | "adjust_balance" | "create_merchant" | "update_merchant_default";
4
+ export interface ActionLogInput {
5
+ correlation_id: string;
6
+ command: ActionCommand;
7
+ user_input?: string | null;
8
+ action_type: ActionType;
9
+ target_id: string;
10
+ payload: Record<string, unknown>;
11
+ }
12
+ export interface ActionLogRow {
13
+ id: string;
14
+ correlation_id: string;
15
+ command: ActionCommand;
16
+ user_input: string | null;
17
+ action_type: ActionType;
18
+ target_id: string;
19
+ payload_json: string;
20
+ created_at: string;
21
+ reverted_at: string | null;
22
+ }
23
+ export declare function appendAction(db: Database.Database, input: ActionLogInput): string;
24
+ export interface ListActionsOptions {
25
+ limit?: number;
26
+ command?: ActionCommand;
27
+ correlationId?: string;
28
+ }
29
+ export declare function listActions(db: Database.Database, opts?: ListActionsOptions): ActionLogRow[];
@@ -0,0 +1,27 @@
1
+ import { randomUUID } from "crypto";
2
+ export function appendAction(db, input) {
3
+ const id = `al:${randomUUID()}`;
4
+ db.prepare(`INSERT INTO action_log
5
+ (id, correlation_id, command, user_input, action_type, target_id, payload_json)
6
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, input.correlation_id, input.command, input.user_input ?? null, input.action_type, input.target_id, JSON.stringify(input.payload));
7
+ return id;
8
+ }
9
+ export function listActions(db, opts = {}) {
10
+ const conds = [];
11
+ const params = [];
12
+ if (opts.command) {
13
+ conds.push("command = ?");
14
+ params.push(opts.command);
15
+ }
16
+ if (opts.correlationId) {
17
+ conds.push("correlation_id = ?");
18
+ params.push(opts.correlationId);
19
+ }
20
+ const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
21
+ const limit = Math.min(Math.max(opts.limit ?? 100, 1), 1000);
22
+ return db
23
+ .prepare(`SELECT id, correlation_id, command, user_input, action_type, target_id,
24
+ payload_json, created_at, reverted_at
25
+ FROM action_log ${where} ORDER BY rowid ASC LIMIT ?`)
26
+ .all(...params, limit);
27
+ }
@@ -0,0 +1,50 @@
1
+ import type Database from "libsql";
2
+ export interface ConcernTarget {
3
+ transaction_id: string | null;
4
+ account_id: string | null;
5
+ }
6
+ export interface RecordConcernInput extends ConcernTarget {
7
+ file_id: string | null;
8
+ kind?: string | null;
9
+ prompt: string;
10
+ options?: string[];
11
+ }
12
+ export interface OpenConcernRow {
13
+ id: string;
14
+ file_id: string | null;
15
+ transaction_id: string | null;
16
+ account_id: string | null;
17
+ kind: string | null;
18
+ prompt: string;
19
+ options_json: string | null;
20
+ created_at: string;
21
+ }
22
+ /**
23
+ * Insert a new concerns row and flip the `has_concern` boolean on whichever
24
+ * target (transaction / account) was named. Returns the new `cn:<uuid>` id.
25
+ */
26
+ export declare function recordConcern(db: Database.Database, input: RecordConcernInput): string;
27
+ /**
28
+ * Mark an existing concern as resolved with the user's answer and, if no other
29
+ * open concerns reference the same target, clear the target's `has_concern`
30
+ * flag. Returns the concern's target so callers can log or react.
31
+ */
32
+ export declare function resolveConcern(db: Database.Database, id: string, answer: string): ConcernTarget | null;
33
+ /**
34
+ * Look up the transaction/account a concern is attached to. Returns null when
35
+ * the concern id doesn't exist.
36
+ */
37
+ export declare function getConcernTarget(db: Database.Database, id: string): ConcernTarget | null;
38
+ /**
39
+ * Clear `has_concern` on the named transaction / account if no other open concerns
40
+ * still reference it. Safe to call after any concern resolution; idempotent.
41
+ */
42
+ export declare function maybeClearHasConcernFlags(db: Database.Database, target: ConcernTarget): void;
43
+ export interface CountOpenConcernsScope {
44
+ file_id?: string;
45
+ transaction_id?: string;
46
+ account_id?: string;
47
+ kind?: string;
48
+ }
49
+ export declare function countOpenConcerns(db: Database.Database, scope?: CountOpenConcernsScope): number;
50
+ export declare function listOpenConcerns(db: Database.Database, limit?: number): OpenConcernRow[];