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.
- 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/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
|
@@ -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(
|
|
16
|
-
COALESCE(SUM(
|
|
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
|
|
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.
|
|
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
|
|
44
|
-
COALESCE(SUM(CASE WHEN a.type = 'expense' THEN
|
|
45
|
-
FROM
|
|
46
|
-
JOIN
|
|
47
|
-
JOIN accounts a ON a.id =
|
|
48
|
-
WHERE
|
|
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
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
113
|
-
WHERE
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
9
|
-
if (input.
|
|
10
|
-
db.prepare(`UPDATE
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
45
|
+
if (target.transaction_id) {
|
|
46
46
|
const open = db
|
|
47
|
-
.prepare(`SELECT 1 FROM concerns WHERE
|
|
48
|
-
.get(target.
|
|
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
|
|
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.
|
|
68
|
-
conditions.push("
|
|
69
|
-
params.push(scope.
|
|
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,
|
|
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
|
+
};
|