plasalid 0.5.8 → 0.6.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 +9 -9
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +7 -6
- package/dist/ai/agent.js +9 -8
- package/dist/ai/personas.d.ts +1 -1
- package/dist/ai/personas.js +69 -66
- package/dist/ai/prompt-sections.d.ts +4 -5
- package/dist/ai/prompt-sections.js +11 -11
- package/dist/ai/system-prompt.d.ts +2 -3
- package/dist/ai/system-prompt.js +5 -5
- package/dist/ai/tools/common.js +13 -5
- package/dist/ai/tools/index.js +15 -15
- package/dist/ai/tools/ingest.d.ts +2 -2
- package/dist/ai/tools/ingest.js +210 -87
- package/dist/ai/tools/merchants.js +27 -12
- package/dist/ai/tools/read.js +36 -20
- package/dist/ai/tools/record.js +79 -19
- package/dist/ai/tools/resolve.d.ts +2 -0
- package/dist/ai/tools/resolve.js +195 -0
- package/dist/ai/tools/types.d.ts +5 -7
- package/dist/cli/commands/accounts.js +2 -2
- package/dist/cli/commands/record.js +4 -2
- package/dist/cli/commands/resolve.d.ts +2 -0
- package/dist/cli/commands/resolve.js +13 -0
- package/dist/cli/commands/scan.js +18 -22
- package/dist/cli/commands/status.js +4 -2
- package/dist/cli/index.js +9 -9
- package/dist/cli/ink/hooks/useFooterText.js +1 -1
- package/dist/cli/ink/hooks/useTextInput.js +0 -3
- package/dist/cli/ink/scan_dashboard.d.ts +2 -2
- package/dist/cli/ink/scan_dashboard.js +3 -3
- package/dist/cli/setup.js +6 -3
- package/dist/cli/ux.js +1 -1
- package/dist/db/queries/account-balance.d.ts +140 -0
- package/dist/db/queries/account-balance.js +355 -0
- package/dist/db/queries/account_balance.d.ts +0 -1
- package/dist/db/queries/account_balance.js +0 -10
- package/dist/db/queries/action-log.d.ts +29 -0
- package/dist/db/queries/action-log.js +27 -0
- package/dist/db/queries/action_log.d.ts +1 -1
- package/dist/db/queries/concerns.d.ts +10 -0
- package/dist/db/queries/concerns.js +21 -0
- package/dist/db/queries/transactions.d.ts +3 -22
- package/dist/db/queries/transactions.js +4 -5
- package/dist/db/queries/unknowns.d.ts +62 -0
- package/dist/db/queries/unknowns.js +114 -0
- package/dist/db/schema.js +3 -3
- package/dist/resolver/pipeline.d.ts +16 -0
- package/dist/resolver/pipeline.js +38 -0
- package/dist/resolver/prompts.d.ts +8 -0
- package/dist/resolver/prompts.js +26 -0
- package/dist/scanner/account-mutex.d.ts +1 -0
- package/dist/scanner/account-mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +10 -10
- package/dist/scanner/buffer.js +15 -15
- package/dist/scanner/decrypt-queue.d.ts +57 -0
- package/dist/scanner/decrypt-queue.js +114 -0
- package/dist/scanner/detectors/correlations.d.ts +2 -0
- package/dist/scanner/detectors/correlations.js +51 -0
- package/dist/scanner/detectors/duplicates.d.ts +2 -0
- package/dist/scanner/detectors/duplicates.js +75 -0
- package/dist/scanner/detectors/index.d.ts +18 -0
- package/dist/scanner/detectors/index.js +39 -0
- package/dist/scanner/detectors/recurrences.d.ts +2 -0
- package/dist/scanner/detectors/recurrences.js +49 -0
- package/dist/scanner/detectors/similar_accounts.d.ts +2 -0
- package/dist/scanner/detectors/similar_accounts.js +64 -0
- package/dist/scanner/detectors/similarities.d.ts +2 -0
- package/dist/scanner/detectors/similarities.js +73 -0
- package/dist/scanner/detectors/types.d.ts +16 -0
- package/dist/scanner/detectors/types.js +1 -0
- package/dist/scanner/inspectors/correlations.d.ts +2 -0
- package/dist/scanner/inspectors/correlations.js +47 -0
- package/dist/scanner/inspectors/duplicates.d.ts +2 -0
- package/dist/scanner/inspectors/duplicates.js +75 -0
- package/dist/scanner/inspectors/index.d.ts +19 -0
- package/dist/scanner/inspectors/index.js +39 -0
- package/dist/scanner/inspectors/recurrences.d.ts +2 -0
- package/dist/scanner/inspectors/recurrences.js +49 -0
- package/dist/scanner/inspectors/similarities.d.ts +2 -0
- package/dist/scanner/inspectors/similarities.js +73 -0
- package/dist/scanner/inspectors/types.d.ts +16 -0
- package/dist/scanner/inspectors/types.js +1 -0
- package/dist/scanner/pipeline.d.ts +6 -4
- package/dist/scanner/pipeline.js +51 -88
- package/dist/scanner/prompts.js +2 -2
- package/package.json +2 -1
|
@@ -0,0 +1,355 @@
|
|
|
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
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Balance per account using the natural debit/credit convention:
|
|
13
|
+
* asset / expense → debit-normal → balance = debits − credits
|
|
14
|
+
* liability / income / equity → credit-normal → balance = credits − debits
|
|
15
|
+
*/
|
|
16
|
+
export function getAccountBalances(db, opts = {}) {
|
|
17
|
+
const params = [];
|
|
18
|
+
const where = [];
|
|
19
|
+
if (opts.type) {
|
|
20
|
+
where.push("a.type = ?");
|
|
21
|
+
params.push(opts.type);
|
|
22
|
+
}
|
|
23
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
24
|
+
const rows = db.prepare(`SELECT a.*,
|
|
25
|
+
COALESCE(SUM(p.debit), 0) AS sum_debit,
|
|
26
|
+
COALESCE(SUM(p.credit), 0) AS sum_credit
|
|
27
|
+
FROM accounts a
|
|
28
|
+
LEFT JOIN postings p ON p.account_id = a.id
|
|
29
|
+
${whereSql}
|
|
30
|
+
GROUP BY a.id
|
|
31
|
+
ORDER BY a.type, a.id`).all(...params);
|
|
32
|
+
return rows.map(r => {
|
|
33
|
+
const debitNormal = r.type === "asset" || r.type === "expense";
|
|
34
|
+
const balance = debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
|
|
35
|
+
const { sum_debit: _d, sum_credit: _c, ...account } = r;
|
|
36
|
+
return { ...account, balance };
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export function getNetWorth(db) {
|
|
40
|
+
const balances = getAccountBalances(db);
|
|
41
|
+
let assets = 0;
|
|
42
|
+
let liabilities = 0;
|
|
43
|
+
for (const b of balances) {
|
|
44
|
+
if (b.type === "asset")
|
|
45
|
+
assets += b.balance;
|
|
46
|
+
else if (b.type === "liability")
|
|
47
|
+
liabilities += b.balance;
|
|
48
|
+
}
|
|
49
|
+
return { assets, liabilities, net_worth: assets - liabilities };
|
|
50
|
+
}
|
|
51
|
+
export function getPeriodTotals(db, from, to) {
|
|
52
|
+
const row = db.prepare(`SELECT
|
|
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);
|
|
59
|
+
return { income: row.income, expenses: row.expenses };
|
|
60
|
+
}
|
|
61
|
+
export function findAccountById(db, id) {
|
|
62
|
+
return db.prepare(`SELECT * FROM accounts WHERE id = ?`).get(id) ?? null;
|
|
63
|
+
}
|
|
64
|
+
export function renameAccount(db, id, name) {
|
|
65
|
+
return db.prepare(`UPDATE accounts SET name = ? WHERE id = ?`).run(name, id).changes;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
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.
|
|
207
|
+
*/
|
|
208
|
+
export function mergeAccounts(db, fromId, toId) {
|
|
209
|
+
if (fromId === toId)
|
|
210
|
+
throw new Error("Cannot merge an account into itself.");
|
|
211
|
+
const from = findAccountById(db, fromId);
|
|
212
|
+
if (!from)
|
|
213
|
+
throw new Error(`Source account ${fromId} not found.`);
|
|
214
|
+
const to = findAccountById(db, toId);
|
|
215
|
+
if (!to)
|
|
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
|
+
}
|
|
223
|
+
let moved = 0;
|
|
224
|
+
const tx = db.transaction(() => {
|
|
225
|
+
moved = db
|
|
226
|
+
.prepare(`UPDATE postings SET account_id = ? WHERE account_id = ?`)
|
|
227
|
+
.run(toId, fromId).changes;
|
|
228
|
+
db.prepare(`DELETE FROM accounts WHERE id = ?`).run(fromId);
|
|
229
|
+
});
|
|
230
|
+
tx();
|
|
231
|
+
return moved;
|
|
232
|
+
}
|
|
233
|
+
/** Delete an account only if no postings reference it AND it has no children. */
|
|
234
|
+
export function deleteAccount(db, id) {
|
|
235
|
+
const inUse = db
|
|
236
|
+
.prepare(`SELECT 1 FROM postings WHERE account_id = ? LIMIT 1`)
|
|
237
|
+
.get(id);
|
|
238
|
+
if (inUse) {
|
|
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.`);
|
|
246
|
+
}
|
|
247
|
+
db.prepare(`DELETE FROM accounts WHERE id = ?`).run(id);
|
|
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
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Pairwise Levenshtein similarity over `accounts.name`. Returns pairs above the
|
|
287
|
+
* threshold (0–1, where 1 = identical), sorted highest first. Quadratic in the
|
|
288
|
+
* number of accounts — fine for the small N a personal chart of accounts holds.
|
|
289
|
+
*/
|
|
290
|
+
export function findSimilarAccounts(db, threshold = 0.85) {
|
|
291
|
+
const rows = db.prepare(`SELECT * FROM accounts ORDER BY name`).all();
|
|
292
|
+
const pairs = [];
|
|
293
|
+
for (let i = 0; i < rows.length; i++) {
|
|
294
|
+
for (let j = i + 1; j < rows.length; j++) {
|
|
295
|
+
const sim = similarity(rows[i].name.toLowerCase(), rows[j].name.toLowerCase());
|
|
296
|
+
if (sim >= threshold)
|
|
297
|
+
pairs.push({ a: rows[i], b: rows[j], similarity: Math.round(sim * 1000) / 1000 });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
pairs.sort((x, y) => y.similarity - x.similarity);
|
|
301
|
+
return pairs;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Rank the chart of accounts by name similarity to a free-text query. Returns
|
|
305
|
+
* matches at or above `threshold`, highest first. Bonus weight when the query
|
|
306
|
+
* is a substring of the name so "ttb saving" still finds "TTB Savings ••1234"
|
|
307
|
+
* even though pure Levenshtein on the full strings is mediocre.
|
|
308
|
+
*/
|
|
309
|
+
export function findAccountsByFuzzyName(db, query, threshold = 0.5) {
|
|
310
|
+
const q = query.trim().toLowerCase();
|
|
311
|
+
if (!q)
|
|
312
|
+
return [];
|
|
313
|
+
const rows = db.prepare(`SELECT * FROM accounts ORDER BY name`).all();
|
|
314
|
+
const out = [];
|
|
315
|
+
for (const row of rows) {
|
|
316
|
+
const name = row.name.toLowerCase();
|
|
317
|
+
let score = similarity(q, name);
|
|
318
|
+
if (name.includes(q) || q.includes(name))
|
|
319
|
+
score = Math.max(score, 0.85);
|
|
320
|
+
if (score >= threshold) {
|
|
321
|
+
out.push({ account: row, similarity: Math.round(score * 1000) / 1000 });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
out.sort((a, b) => b.similarity - a.similarity);
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
function similarity(a, b) {
|
|
328
|
+
if (a === b)
|
|
329
|
+
return 1;
|
|
330
|
+
if (a.length === 0 || b.length === 0)
|
|
331
|
+
return 0;
|
|
332
|
+
const dist = levenshtein(a, b);
|
|
333
|
+
return 1 - dist / Math.max(a.length, b.length);
|
|
334
|
+
}
|
|
335
|
+
function levenshtein(a, b) {
|
|
336
|
+
const m = a.length, n = b.length;
|
|
337
|
+
if (m === 0)
|
|
338
|
+
return n;
|
|
339
|
+
if (n === 0)
|
|
340
|
+
return m;
|
|
341
|
+
const prev = new Array(n + 1);
|
|
342
|
+
const curr = new Array(n + 1);
|
|
343
|
+
for (let j = 0; j <= n; j++)
|
|
344
|
+
prev[j] = j;
|
|
345
|
+
for (let i = 1; i <= m; i++) {
|
|
346
|
+
curr[0] = i;
|
|
347
|
+
for (let j = 1; j <= n; j++) {
|
|
348
|
+
const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1;
|
|
349
|
+
curr[j] = Math.min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost);
|
|
350
|
+
}
|
|
351
|
+
for (let j = 0; j <= n; j++)
|
|
352
|
+
prev[j] = curr[j];
|
|
353
|
+
}
|
|
354
|
+
return prev[n];
|
|
355
|
+
}
|
|
@@ -127,7 +127,6 @@ export interface SimilarAccountPair {
|
|
|
127
127
|
* number of accounts — fine for the small N a personal chart of accounts holds.
|
|
128
128
|
*/
|
|
129
129
|
export declare function findSimilarAccounts(db: Database.Database, threshold?: number): SimilarAccountPair[];
|
|
130
|
-
export declare function findUnusedAccounts(db: Database.Database): AccountRow[];
|
|
131
130
|
export interface FuzzyAccountMatch {
|
|
132
131
|
account: AccountRow;
|
|
133
132
|
similarity: number;
|
|
@@ -300,16 +300,6 @@ export function findSimilarAccounts(db, threshold = 0.85) {
|
|
|
300
300
|
pairs.sort((x, y) => y.similarity - x.similarity);
|
|
301
301
|
return pairs;
|
|
302
302
|
}
|
|
303
|
-
export function findUnusedAccounts(db) {
|
|
304
|
-
return db
|
|
305
|
-
.prepare(`SELECT a.* FROM accounts a
|
|
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')
|
|
310
|
-
ORDER BY a.name`)
|
|
311
|
-
.all();
|
|
312
|
-
}
|
|
313
303
|
/**
|
|
314
304
|
* Rank the chart of accounts by name similarity to a free-text query. Returns
|
|
315
305
|
* matches at or above `threshold`, highest first. Bonus weight when the query
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
export type ActionCommand = "record" | "scan" | "resolve";
|
|
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,5 +1,5 @@
|
|
|
1
1
|
import type Database from "libsql";
|
|
2
|
-
export type ActionCommand = "record" | "scan" | "
|
|
2
|
+
export type ActionCommand = "record" | "scan" | "resolve";
|
|
3
3
|
export type ActionType = "create_account" | "update_account_metadata" | "record_transaction" | "adjust_balance" | "create_merchant" | "update_merchant_default";
|
|
4
4
|
export interface ActionLogInput {
|
|
5
5
|
correlation_id: string;
|
|
@@ -48,3 +48,13 @@ export interface CountOpenConcernsScope {
|
|
|
48
48
|
}
|
|
49
49
|
export declare function countOpenConcerns(db: Database.Database, scope?: CountOpenConcernsScope): number;
|
|
50
50
|
export declare function listOpenConcerns(db: Database.Database, limit?: number): OpenConcernRow[];
|
|
51
|
+
/**
|
|
52
|
+
* Open concerns filtered by `kind`, ordered by the position of the kind in the
|
|
53
|
+
* input array (priority) then by created_at. Pass `["uncategorized","duplicate"]`
|
|
54
|
+
* to drain uncategorized concerns before duplicates.
|
|
55
|
+
*
|
|
56
|
+
* `kind` is free-text TEXT in the schema; canonical values used by built-ins:
|
|
57
|
+
* uncategorized, duplicate, correlation, recurrence_candidate,
|
|
58
|
+
* similar_accounts, file_password
|
|
59
|
+
*/
|
|
60
|
+
export declare function listOpenConcernsByKind(db: Database.Database, kinds: string[], limit?: number): OpenConcernRow[];
|
|
@@ -89,3 +89,24 @@ export function listOpenConcerns(db, limit = 50) {
|
|
|
89
89
|
ORDER BY created_at ASC
|
|
90
90
|
LIMIT ?`).all(capped);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* Open concerns filtered by `kind`, ordered by the position of the kind in the
|
|
94
|
+
* input array (priority) then by created_at. Pass `["uncategorized","duplicate"]`
|
|
95
|
+
* to drain uncategorized concerns before duplicates.
|
|
96
|
+
*
|
|
97
|
+
* `kind` is free-text TEXT in the schema; canonical values used by built-ins:
|
|
98
|
+
* uncategorized, duplicate, correlation, recurrence_candidate,
|
|
99
|
+
* similar_accounts, file_password
|
|
100
|
+
*/
|
|
101
|
+
export function listOpenConcernsByKind(db, kinds, limit = 50) {
|
|
102
|
+
if (kinds.length === 0)
|
|
103
|
+
return [];
|
|
104
|
+
const capped = Math.min(Math.max(limit, 1), 200);
|
|
105
|
+
const placeholders = kinds.map(() => "?").join(",");
|
|
106
|
+
const cases = kinds.map((_, i) => `WHEN ? THEN ${i}`).join(" ");
|
|
107
|
+
return db.prepare(`SELECT id, file_id, transaction_id, account_id, kind, prompt, options_json, created_at
|
|
108
|
+
FROM concerns
|
|
109
|
+
WHERE resolved_at IS NULL AND kind IN (${placeholders})
|
|
110
|
+
ORDER BY CASE kind ${cases} ELSE ${kinds.length} END, created_at ASC
|
|
111
|
+
LIMIT ?`).all(...kinds, ...kinds, capped);
|
|
112
|
+
}
|
|
@@ -9,7 +9,7 @@ export interface PostingInput {
|
|
|
9
9
|
pii_flag?: boolean;
|
|
10
10
|
}
|
|
11
11
|
export interface TransactionInput {
|
|
12
|
-
/** Optional pre-assigned id. Used by the buffered-write path so
|
|
12
|
+
/** Optional pre-assigned id. Used by the buffered-write path so unknowns recorded mid-scan can reference the transaction before commit. */
|
|
13
13
|
id?: string;
|
|
14
14
|
date: string;
|
|
15
15
|
description: string;
|
|
@@ -90,6 +90,8 @@ export interface DuplicateGroupTransaction {
|
|
|
90
90
|
date: string;
|
|
91
91
|
description: string;
|
|
92
92
|
amount: number;
|
|
93
|
+
source_file_id: string | null;
|
|
94
|
+
merchant_id: string | null;
|
|
93
95
|
account_ids: string[];
|
|
94
96
|
account_names: string[];
|
|
95
97
|
}
|
|
@@ -143,25 +145,4 @@ export interface FindCorrelatedTransactionsOptions {
|
|
|
143
145
|
* pairs whose account-id sets overlap (those are duplicates, not correlations).
|
|
144
146
|
*/
|
|
145
147
|
export declare function findCorrelatedTransactions(db: Database.Database, opts?: FindCorrelatedTransactionsOptions): CorrelatedTransactionPair[];
|
|
146
|
-
export interface CorrelationCandidate {
|
|
147
|
-
id: string;
|
|
148
|
-
date: string;
|
|
149
|
-
description: string;
|
|
150
|
-
amount: number;
|
|
151
|
-
currency: string;
|
|
152
|
-
account_ids: string[];
|
|
153
|
-
account_names: string[];
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* Pure pair-finder: given an array of candidates already filtered by amount
|
|
157
|
-
* and equipped with account_ids/names, return the cross-pairs that look like
|
|
158
|
-
* the same money movement on different accounts (date within toleranceDays,
|
|
159
|
-
* same amount + currency, non-overlapping account sets).
|
|
160
|
-
*
|
|
161
|
-
* Used by the DB-backed `findCorrelatedTransactions` and by the scan-time
|
|
162
|
-
* coordinator that runs over buffered, not-yet-committed transactions.
|
|
163
|
-
*/
|
|
164
|
-
export declare function correlatePairs(candidates: CorrelationCandidate[], opts?: {
|
|
165
|
-
toleranceDays?: number;
|
|
166
|
-
}): CorrelatedTransactionPair[];
|
|
167
148
|
export declare function listPostings(db: Database.Database, opts?: ListPostingsOptions): PostingRow[];
|
|
@@ -123,7 +123,7 @@ export function findDuplicateTransactions(db, opts = {}) {
|
|
|
123
123
|
: ``;
|
|
124
124
|
const params = opts.accountId ? [opts.accountId] : [];
|
|
125
125
|
const nameById = loadAccountNames(db);
|
|
126
|
-
const rows = db.prepare(`SELECT t.id, t.date, t.description,
|
|
126
|
+
const rows = db.prepare(`SELECT t.id, t.date, t.description, t.source_file_id, t.merchant_id,
|
|
127
127
|
COALESCE(SUM(p.debit), 0) AS amount,
|
|
128
128
|
GROUP_CONCAT(p.account_id) AS account_ids
|
|
129
129
|
FROM transactions t
|
|
@@ -139,6 +139,8 @@ export function findDuplicateTransactions(db, opts = {}) {
|
|
|
139
139
|
date: r.date,
|
|
140
140
|
description: r.description,
|
|
141
141
|
amount: Math.round(r.amount * 100) / 100,
|
|
142
|
+
source_file_id: r.source_file_id,
|
|
143
|
+
merchant_id: r.merchant_id,
|
|
142
144
|
account_ids: ids,
|
|
143
145
|
account_names: ids.map(id => nameById.get(id) ?? id),
|
|
144
146
|
};
|
|
@@ -246,11 +248,8 @@ export function findCorrelatedTransactions(db, opts = {}) {
|
|
|
246
248
|
* and equipped with account_ids/names, return the cross-pairs that look like
|
|
247
249
|
* the same money movement on different accounts (date within toleranceDays,
|
|
248
250
|
* same amount + currency, non-overlapping account sets).
|
|
249
|
-
*
|
|
250
|
-
* Used by the DB-backed `findCorrelatedTransactions` and by the scan-time
|
|
251
|
-
* coordinator that runs over buffered, not-yet-committed transactions.
|
|
252
251
|
*/
|
|
253
|
-
|
|
252
|
+
function correlatePairs(candidates, opts = {}) {
|
|
254
253
|
const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
|
|
255
254
|
const buckets = new Map();
|
|
256
255
|
for (const e of candidates) {
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
export interface UnknownTarget {
|
|
3
|
+
transaction_id: string | null;
|
|
4
|
+
account_id: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface RecordUnknownInput extends UnknownTarget {
|
|
7
|
+
file_id: string | null;
|
|
8
|
+
kind?: string | null;
|
|
9
|
+
prompt: string;
|
|
10
|
+
options?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface OpenUnknownRow {
|
|
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 unknowns row and flip the `has_unknown` boolean on whichever
|
|
24
|
+
* target (transaction / account) was named. Returns the new id. The id keeps
|
|
25
|
+
* the historical `cn:` prefix — it's opaque and nothing else references it,
|
|
26
|
+
* so the prefix is a no-op detail.
|
|
27
|
+
*/
|
|
28
|
+
export declare function recordUnknown(db: Database.Database, input: RecordUnknownInput): string;
|
|
29
|
+
/**
|
|
30
|
+
* Mark an existing unknown as resolved with the user's answer and, if no other
|
|
31
|
+
* open unknowns reference the same target, clear the target's `has_unknown`
|
|
32
|
+
* flag. Returns the unknown's target so callers can log or react.
|
|
33
|
+
*/
|
|
34
|
+
export declare function resolveUnknown(db: Database.Database, id: string, answer: string): UnknownTarget | null;
|
|
35
|
+
/**
|
|
36
|
+
* Look up the transaction/account an unknown is attached to. Returns null when
|
|
37
|
+
* the unknown id doesn't exist.
|
|
38
|
+
*/
|
|
39
|
+
export declare function getUnknownTarget(db: Database.Database, id: string): UnknownTarget | null;
|
|
40
|
+
/**
|
|
41
|
+
* Clear `has_unknown` on the named transaction / account if no other open
|
|
42
|
+
* unknowns still reference it. Safe to call after any resolution; idempotent.
|
|
43
|
+
*/
|
|
44
|
+
export declare function maybeClearHasUnknownFlags(db: Database.Database, target: UnknownTarget): void;
|
|
45
|
+
export interface CountOpenUnknownsScope {
|
|
46
|
+
file_id?: string;
|
|
47
|
+
transaction_id?: string;
|
|
48
|
+
account_id?: string;
|
|
49
|
+
kind?: string;
|
|
50
|
+
}
|
|
51
|
+
export declare function countOpenUnknowns(db: Database.Database, scope?: CountOpenUnknownsScope): number;
|
|
52
|
+
export declare function listOpenUnknowns(db: Database.Database, limit?: number): OpenUnknownRow[];
|
|
53
|
+
/**
|
|
54
|
+
* Open unknowns filtered by `kind`, ordered by the position of the kind in the
|
|
55
|
+
* input array (priority) then by created_at. Pass `["uncategorized","duplicate"]`
|
|
56
|
+
* to drain uncategorized rows before duplicates.
|
|
57
|
+
*
|
|
58
|
+
* `kind` is free-text TEXT in the schema; canonical values used by built-ins:
|
|
59
|
+
* uncategorized, duplicate, correlation, recurrence_candidate,
|
|
60
|
+
* similar_accounts, file_password
|
|
61
|
+
*/
|
|
62
|
+
export declare function listOpenUnknownsByKind(db: Database.Database, kinds: string[], limit?: number): OpenUnknownRow[];
|