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.
Files changed (66) 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/hooks/useFooterText.js +2 -1
  36. package/dist/cli/ink/scan_dashboard.d.ts +1 -1
  37. package/dist/cli/ink/scan_dashboard.js +2 -2
  38. package/dist/cli/setup.d.ts +0 -1
  39. package/dist/cli/setup.js +2 -8
  40. package/dist/currency.d.ts +3 -0
  41. package/dist/currency.js +12 -1
  42. package/dist/db/queries/account_balance.d.ts +83 -4
  43. package/dist/db/queries/account_balance.js +239 -20
  44. package/dist/db/queries/action_log.d.ts +29 -0
  45. package/dist/db/queries/action_log.js +27 -0
  46. package/dist/db/queries/concerns.d.ts +10 -7
  47. package/dist/db/queries/concerns.js +20 -16
  48. package/dist/db/queries/journal.d.ts +1 -0
  49. package/dist/db/queries/merchants.d.ts +42 -0
  50. package/dist/db/queries/merchants.js +120 -0
  51. package/dist/db/queries/recurrences.d.ts +3 -3
  52. package/dist/db/queries/recurrences.js +32 -34
  53. package/dist/db/queries/search.d.ts +5 -4
  54. package/dist/db/queries/search.js +16 -12
  55. package/dist/db/queries/transactions.d.ts +167 -0
  56. package/dist/db/queries/transactions.js +320 -0
  57. package/dist/db/schema.js +51 -9
  58. package/dist/reviewer/pipeline.d.ts +4 -4
  59. package/dist/reviewer/pipeline.js +4 -4
  60. package/dist/reviewer/prompts.js +4 -4
  61. package/dist/scanner/buffer.d.ts +24 -21
  62. package/dist/scanner/buffer.js +18 -18
  63. package/dist/scanner/pipeline.d.ts +3 -2
  64. package/dist/scanner/pipeline.js +33 -36
  65. package/dist/scanner/prompts.js +3 -3
  66. package/package.json +2 -2
@@ -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
+ }
@@ -1,25 +1,27 @@
1
1
  import type Database from "libsql";
2
2
  export interface ConcernTarget {
3
- entry_id: string | null;
3
+ transaction_id: string | null;
4
4
  account_id: string | null;
5
5
  }
6
6
  export interface RecordConcernInput extends ConcernTarget {
7
7
  file_id: string | null;
8
+ kind?: string | null;
8
9
  prompt: string;
9
10
  options?: string[];
10
11
  }
11
12
  export interface OpenConcernRow {
12
13
  id: string;
13
14
  file_id: string | null;
14
- entry_id: string | null;
15
+ transaction_id: string | null;
15
16
  account_id: string | null;
17
+ kind: string | null;
16
18
  prompt: string;
17
19
  options_json: string | null;
18
20
  created_at: string;
19
21
  }
20
22
  /**
21
23
  * Insert a new concerns row and flip the `has_concern` boolean on whichever
22
- * target (entry / account) was named. Returns the new `cn:<uuid>` id.
24
+ * target (transaction / account) was named. Returns the new `cn:<uuid>` id.
23
25
  */
24
26
  export declare function recordConcern(db: Database.Database, input: RecordConcernInput): string;
25
27
  /**
@@ -29,19 +31,20 @@ export declare function recordConcern(db: Database.Database, input: RecordConcer
29
31
  */
30
32
  export declare function resolveConcern(db: Database.Database, id: string, answer: string): ConcernTarget | null;
31
33
  /**
32
- * Look up the entry/account a concern is attached to. Returns null when the
33
- * concern id doesn't exist.
34
+ * Look up the transaction/account a concern is attached to. Returns null when
35
+ * the concern id doesn't exist.
34
36
  */
35
37
  export declare function getConcernTarget(db: Database.Database, id: string): ConcernTarget | null;
36
38
  /**
37
- * Clear `has_concern` on the named entry / account if no other open concerns
39
+ * Clear `has_concern` on the named transaction / account if no other open concerns
38
40
  * still reference it. Safe to call after any concern resolution; idempotent.
39
41
  */
40
42
  export declare function maybeClearHasConcernFlags(db: Database.Database, target: ConcernTarget): void;
41
43
  export interface CountOpenConcernsScope {
42
44
  file_id?: string;
43
- entry_id?: string;
45
+ transaction_id?: string;
44
46
  account_id?: string;
47
+ kind?: string;
45
48
  }
46
49
  export declare function countOpenConcerns(db: Database.Database, scope?: CountOpenConcernsScope): number;
47
50
  export declare function listOpenConcerns(db: Database.Database, limit?: number): OpenConcernRow[];
@@ -1,13 +1,13 @@
1
1
  import { randomUUID } from "crypto";
2
2
  /**
3
3
  * Insert a new concerns row and flip the `has_concern` boolean on whichever
4
- * target (entry / account) was named. Returns the new `cn:<uuid>` id.
4
+ * target (transaction / account) was named. Returns the new `cn:<uuid>` id.
5
5
  */
6
6
  export function recordConcern(db, input) {
7
7
  const id = `cn:${randomUUID()}`;
8
- db.prepare(`INSERT INTO concerns (id, file_id, entry_id, account_id, prompt, options_json) VALUES (?, ?, ?, ?, ?, ?)`).run(id, input.file_id, input.entry_id, input.account_id, input.prompt, input.options ? JSON.stringify(input.options) : null);
9
- if (input.entry_id) {
10
- db.prepare(`UPDATE journal_entries SET has_concern = 1 WHERE id = ?`).run(input.entry_id);
8
+ db.prepare(`INSERT INTO concerns (id, file_id, transaction_id, account_id, kind, prompt, options_json) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, input.file_id, input.transaction_id, input.account_id, input.kind ?? null, input.prompt, input.options ? JSON.stringify(input.options) : null);
9
+ if (input.transaction_id) {
10
+ db.prepare(`UPDATE transactions SET has_concern = 1 WHERE id = ?`).run(input.transaction_id);
11
11
  }
12
12
  if (input.account_id) {
13
13
  db.prepare(`UPDATE accounts SET has_concern = 1 WHERE id = ?`).run(input.account_id);
@@ -28,26 +28,26 @@ export function resolveConcern(db, id, answer) {
28
28
  return target;
29
29
  }
30
30
  /**
31
- * Look up the entry/account a concern is attached to. Returns null when the
32
- * concern id doesn't exist.
31
+ * Look up the transaction/account a concern is attached to. Returns null when
32
+ * the concern id doesn't exist.
33
33
  */
34
34
  export function getConcernTarget(db, id) {
35
35
  const row = db
36
- .prepare(`SELECT entry_id, account_id FROM concerns WHERE id = ?`)
36
+ .prepare(`SELECT transaction_id, account_id FROM concerns WHERE id = ?`)
37
37
  .get(id);
38
38
  return row ?? null;
39
39
  }
40
40
  /**
41
- * Clear `has_concern` on the named entry / account if no other open concerns
41
+ * Clear `has_concern` on the named transaction / account if no other open concerns
42
42
  * still reference it. Safe to call after any concern resolution; idempotent.
43
43
  */
44
44
  export function maybeClearHasConcernFlags(db, target) {
45
- if (target.entry_id) {
45
+ if (target.transaction_id) {
46
46
  const open = db
47
- .prepare(`SELECT 1 FROM concerns WHERE entry_id = ? AND resolved_at IS NULL LIMIT 1`)
48
- .get(target.entry_id);
47
+ .prepare(`SELECT 1 FROM concerns WHERE transaction_id = ? AND resolved_at IS NULL LIMIT 1`)
48
+ .get(target.transaction_id);
49
49
  if (!open)
50
- db.prepare(`UPDATE journal_entries SET has_concern = 0 WHERE id = ?`).run(target.entry_id);
50
+ db.prepare(`UPDATE transactions SET has_concern = 0 WHERE id = ?`).run(target.transaction_id);
51
51
  }
52
52
  if (target.account_id) {
53
53
  const open = db
@@ -64,14 +64,18 @@ export function countOpenConcerns(db, scope = {}) {
64
64
  conditions.push("file_id = ?");
65
65
  params.push(scope.file_id);
66
66
  }
67
- if (scope.entry_id) {
68
- conditions.push("entry_id = ?");
69
- params.push(scope.entry_id);
67
+ if (scope.transaction_id) {
68
+ conditions.push("transaction_id = ?");
69
+ params.push(scope.transaction_id);
70
70
  }
71
71
  if (scope.account_id) {
72
72
  conditions.push("account_id = ?");
73
73
  params.push(scope.account_id);
74
74
  }
75
+ if (scope.kind) {
76
+ conditions.push("kind = ?");
77
+ params.push(scope.kind);
78
+ }
75
79
  const row = db
76
80
  .prepare(`SELECT COUNT(*) AS n FROM concerns WHERE ${conditions.join(" AND ")}`)
77
81
  .get(...params);
@@ -79,7 +83,7 @@ export function countOpenConcerns(db, scope = {}) {
79
83
  }
80
84
  export function listOpenConcerns(db, limit = 50) {
81
85
  const capped = Math.min(Math.max(limit, 1), 200);
82
- return db.prepare(`SELECT id, file_id, entry_id, account_id, prompt, options_json, created_at
86
+ return db.prepare(`SELECT id, file_id, transaction_id, account_id, kind, prompt, options_json, created_at
83
87
  FROM concerns
84
88
  WHERE resolved_at IS NULL
85
89
  ORDER BY created_at ASC
@@ -103,6 +103,7 @@ export interface FindDuplicateEntriesOptions {
103
103
  */
104
104
  export declare function findDuplicateEntries(db: Database.Database, opts?: FindDuplicateEntriesOptions): DuplicateGroupEntry[][];
105
105
  export declare function dayDiff(a: string, b: string): number;
106
+ /** Correlations */
106
107
  export interface CorrelatedEntryPair {
107
108
  amount: number;
108
109
  currency: string;
@@ -0,0 +1,42 @@
1
+ import type Database from "libsql";
2
+ export interface MerchantUpsertInput {
3
+ canonical_name: string;
4
+ alias?: string;
5
+ default_account_id?: string | null;
6
+ }
7
+ export interface MerchantRow {
8
+ id: string;
9
+ canonical_name: string;
10
+ default_account_id: string | null;
11
+ created_at: string;
12
+ }
13
+ export declare function normalizeDescriptor(raw: string): string;
14
+ /**
15
+ * Upsert a merchant by canonical_name. Optionally upsert an alias and update
16
+ * the cached default_account_id. Idempotent: re-running with the same inputs
17
+ * does not duplicate rows. Designed to be called inside the same DB transaction
18
+ * as the posting writes so a transaction never lands without its merchant.
19
+ */
20
+ export declare function upsertMerchant(db: Database.Database, input: MerchantUpsertInput): MerchantRow;
21
+ export interface MerchantWithDefault {
22
+ merchant: MerchantRow;
23
+ default_account_id: string | null;
24
+ }
25
+ /**
26
+ * Resolve a raw PDF descriptor to a known merchant via the alias table.
27
+ * Returns null if no alias matches. The scanner uses this in its pre-resolution
28
+ * pass so the LLM can skip re-categorizing already-seen merchants.
29
+ */
30
+ export declare function findMerchantByAlias(db: Database.Database, rawDescriptor: string): MerchantWithDefault | null;
31
+ export interface ListMerchantsOptions {
32
+ withDefaultOnly?: boolean;
33
+ limit?: number;
34
+ }
35
+ export declare function listMerchants(db: Database.Database, opts?: ListMerchantsOptions): (MerchantRow & {
36
+ alias_count: number;
37
+ })[];
38
+ export declare function findMerchantById(db: Database.Database, id: string): MerchantRow | null;
39
+ export declare function setMerchantDefaultAccount(db: Database.Database, merchantId: string, accountId: string): {
40
+ before: string | null;
41
+ after: string;
42
+ };