@morroc/open-ledger 0.14.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.
- package/LICENSE +202 -0
- package/README.md +191 -0
- package/datasets/defaults/cn.json +1 -0
- package/datasets/defaults/jp.json +1 -0
- package/datasets/defaults/th.json +1 -0
- package/datasets/defaults/us.json +1 -0
- package/datasets/institutions/cn.json +47 -0
- package/datasets/institutions/jp.json +59 -0
- package/datasets/institutions/th.json +99 -0
- package/datasets/institutions/us.json +70 -0
- package/dist/accounts/accounts.js +99 -0
- package/dist/accounts/balances.js +109 -0
- package/dist/accounts/matching.js +72 -0
- package/dist/accounts/resolve.js +118 -0
- package/dist/cli/commands/accounts.js +477 -0
- package/dist/cli/commands/config.js +175 -0
- package/dist/cli/commands/datasets.js +56 -0
- package/dist/cli/commands/doctor.js +153 -0
- package/dist/cli/commands/files.js +72 -0
- package/dist/cli/commands/ingest-commit.js +279 -0
- package/dist/cli/commands/ingest.js +203 -0
- package/dist/cli/commands/merchants.js +140 -0
- package/dist/cli/commands/notes.js +71 -0
- package/dist/cli/commands/open.js +54 -0
- package/dist/cli/commands/questions.js +119 -0
- package/dist/cli/commands/report.js +50 -0
- package/dist/cli/commands/setup.js +61 -0
- package/dist/cli/commands/status.js +187 -0
- package/dist/cli/commands/transactions.js +420 -0
- package/dist/cli/commands/vault.js +65 -0
- package/dist/cli/currency.js +28 -0
- package/dist/cli/db.js +5 -0
- package/dist/cli/format.js +71 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/output.js +304 -0
- package/dist/cli/program.js +140 -0
- package/dist/config.js +104 -0
- package/dist/context.js +30 -0
- package/dist/datasets/defaults.js +29 -0
- package/dist/datasets/index.js +33 -0
- package/dist/datasets/institutions.js +31 -0
- package/dist/datasets/loader.js +57 -0
- package/dist/db/connection.js +39 -0
- package/dist/db/encryption.js +42 -0
- package/dist/db/migrations/0001_baseline.js +121 -0
- package/dist/db/migrations/index.js +3 -0
- package/dist/db/queries/accounts.js +108 -0
- package/dist/db/queries/balances.js +61 -0
- package/dist/db/queries/files.js +72 -0
- package/dist/db/queries/merchants.js +163 -0
- package/dist/db/queries/notes.js +28 -0
- package/dist/db/queries/questions.js +117 -0
- package/dist/db/queries/transactions-dedup.js +80 -0
- package/dist/db/queries/transactions.js +319 -0
- package/dist/db/queries/vault.js +48 -0
- package/dist/db/schema.js +105 -0
- package/dist/extract/extract.js +89 -0
- package/dist/extract/ocr.js +164 -0
- package/dist/extract/pdf.js +127 -0
- package/dist/extract/presets/index.js +18 -0
- package/dist/extract/presets/lighton-ocr.js +12 -0
- package/dist/extract/presets/typhoon-ocr.js +23 -0
- package/dist/extract/route.js +40 -0
- package/dist/extract/source.js +84 -0
- package/dist/ingest/commit.js +313 -0
- package/dist/ingest/dedup.js +35 -0
- package/dist/ingest/prepare.js +241 -0
- package/dist/ingest/vault.js +56 -0
- package/dist/lib/date.js +6 -0
- package/dist/lib/ids.js +25 -0
- package/dist/lib/json.js +12 -0
- package/dist/lib/masked.js +49 -0
- package/dist/lib/money.js +35 -0
- package/dist/lib/patch.js +29 -0
- package/dist/lib/result.js +15 -0
- package/dist/lib/validate.js +154 -0
- package/dist/privacy/redactor.js +140 -0
- package/dist/setup/hosts.js +32 -0
- package/dist/setup/install.js +60 -0
- package/package.json +60 -0
- package/skills/SKILL.md +18 -0
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** Missing status buckets are filled with 0 so callers get a stable shape without null checks. */
|
|
2
|
+
export function countFiles(db) {
|
|
3
|
+
const rows = db
|
|
4
|
+
.prepare(`SELECT status, COUNT(*) AS n FROM files GROUP BY status`)
|
|
5
|
+
.all();
|
|
6
|
+
const totals = { ingested: 0, pending: 0, failed: 0 };
|
|
7
|
+
for (const row of rows) {
|
|
8
|
+
if (row.status === "ingested" || row.status === "pending" || row.status === "failed") {
|
|
9
|
+
totals[row.status] = row.n;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
return totals;
|
|
13
|
+
}
|
|
14
|
+
const FILE_SELECT = `SELECT id, path, file_hash, mime, status, ingested_at, source, error, created_at
|
|
15
|
+
FROM files`;
|
|
16
|
+
export function listFiles(db) {
|
|
17
|
+
return db
|
|
18
|
+
.prepare(`${FILE_SELECT} ORDER BY ingested_at DESC, created_at DESC`)
|
|
19
|
+
.all();
|
|
20
|
+
}
|
|
21
|
+
export function findFileById(db, id) {
|
|
22
|
+
const row = db.prepare(`${FILE_SELECT} WHERE id = ?`).get(id);
|
|
23
|
+
return row ?? null;
|
|
24
|
+
}
|
|
25
|
+
/** The row a file's bytes already registered as; `file_hash` is UNIQUE, so a content match is exactly one row. */
|
|
26
|
+
export function findFileByHash(db, hash) {
|
|
27
|
+
const row = db.prepare(`${FILE_SELECT} WHERE file_hash = ?`).get(hash);
|
|
28
|
+
return row ?? null;
|
|
29
|
+
}
|
|
30
|
+
export function insertPendingFile(db, file) {
|
|
31
|
+
db.prepare(`INSERT INTO files (id, path, file_hash, mime, status) VALUES (?, ?, ?, ?, 'pending')`).run(file.id, file.path, file.file_hash, file.mime);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Delete leads (file_hash is UNIQUE) then re-insert, atomically; the delete cascades away
|
|
35
|
+
* the prior row's transactions and questions (ON DELETE CASCADE).
|
|
36
|
+
*/
|
|
37
|
+
export function replaceFile(db, priorId, file) {
|
|
38
|
+
const tx = db.transaction(() => {
|
|
39
|
+
deleteFile(db, priorId);
|
|
40
|
+
insertPendingFile(db, file);
|
|
41
|
+
});
|
|
42
|
+
tx();
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Cascaded transaction/question counts are gathered before the DELETE runs —
|
|
46
|
+
* CASCADE would make them unrecoverable after.
|
|
47
|
+
*/
|
|
48
|
+
export function deleteFile(db, id) {
|
|
49
|
+
const removed = findFileById(db, id);
|
|
50
|
+
if (!removed) {
|
|
51
|
+
return { removed: null, removedTransactions: 0, removedQuestions: 0 };
|
|
52
|
+
}
|
|
53
|
+
const removedTransactions = db
|
|
54
|
+
.prepare(`SELECT COUNT(*) AS n FROM transactions WHERE source_file_id = ?`)
|
|
55
|
+
.get(id).n;
|
|
56
|
+
const removedQuestions = db
|
|
57
|
+
.prepare(`SELECT COUNT(*) AS n FROM questions WHERE file_id = ?`)
|
|
58
|
+
.get(id).n;
|
|
59
|
+
db.prepare(`DELETE FROM files WHERE id = ?`).run(id);
|
|
60
|
+
return { removed, removedTransactions, removedQuestions };
|
|
61
|
+
}
|
|
62
|
+
export function markFileIngested(db, fileId, opts) {
|
|
63
|
+
return db
|
|
64
|
+
.prepare(`UPDATE files SET status = 'ingested', ingested_at = datetime('now'), source = ? WHERE id = ?`)
|
|
65
|
+
.run(opts.source ?? null, fileId).changes;
|
|
66
|
+
}
|
|
67
|
+
/** ingested_at is left untouched: a failed file was never successfully ingested. */
|
|
68
|
+
export function markFileFailed(db, fileId, opts) {
|
|
69
|
+
return db
|
|
70
|
+
.prepare(`UPDATE files SET status = 'failed', source = ?, error = ? WHERE id = ?`)
|
|
71
|
+
.run(opts.source ?? null, opts.error, fileId).changes;
|
|
72
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Strips store ids, terminal codes, city tags, and transaction-type words so
|
|
4
|
+
* "STARBUCKS #1234 BKK CHARGE" and "Starbucks #5678 BANGKOK" both normalize
|
|
5
|
+
* to "starbucks" — the form `merchant_aliases.normalized_pattern` indexes.
|
|
6
|
+
*/
|
|
7
|
+
const NOISE_TOKENS = new Set([
|
|
8
|
+
"bkk", "bangkok", "thailand", "th", "tha",
|
|
9
|
+
"charge", "purchase", "payment", "pmt", "ref", "txn", "trx", "tx",
|
|
10
|
+
"pos", "atm", "online", "web", "mobile", "app",
|
|
11
|
+
"co", "ltd", "company", "inc", "llc", "plc", "intl",
|
|
12
|
+
]);
|
|
13
|
+
export function normalizeDescriptor(raw) {
|
|
14
|
+
if (!raw)
|
|
15
|
+
return "";
|
|
16
|
+
const lowered = raw.toLowerCase();
|
|
17
|
+
const stripped = lowered
|
|
18
|
+
.replace(/[#*][a-z0-9]+/gi, " ")
|
|
19
|
+
.replace(/\b\d{2,}\b/g, " ")
|
|
20
|
+
.replace(/[^a-z0-9\s]+/g, " ")
|
|
21
|
+
.replace(/\s+/g, " ")
|
|
22
|
+
.trim();
|
|
23
|
+
if (!stripped)
|
|
24
|
+
return "";
|
|
25
|
+
const tokens = stripped.split(" ").filter(t => t.length > 1 && !NOISE_TOKENS.has(t));
|
|
26
|
+
if (tokens.length === 0)
|
|
27
|
+
return stripped;
|
|
28
|
+
return tokens.join(" ");
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Meant to run inside the same DB transaction as the write it serves, so a
|
|
32
|
+
* transaction never lands without its merchant.
|
|
33
|
+
*/
|
|
34
|
+
export function upsertMerchant(db, input) {
|
|
35
|
+
const canonical = input.canonical_name.trim();
|
|
36
|
+
if (!canonical) {
|
|
37
|
+
throw new Error("merchant canonical_name is required");
|
|
38
|
+
}
|
|
39
|
+
const existing = db
|
|
40
|
+
.prepare(`SELECT id, canonical_name, default_account_id, created_at FROM merchants WHERE canonical_name = ?`)
|
|
41
|
+
.get(canonical);
|
|
42
|
+
let merchant;
|
|
43
|
+
if (existing) {
|
|
44
|
+
merchant = existing;
|
|
45
|
+
if (input.default_account_id && input.default_account_id !== existing.default_account_id) {
|
|
46
|
+
db.prepare(`UPDATE merchants SET default_account_id = ? WHERE id = ?`)
|
|
47
|
+
.run(input.default_account_id, existing.id);
|
|
48
|
+
merchant = { ...existing, default_account_id: input.default_account_id };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
const id = `m:${randomUUID()}`;
|
|
53
|
+
db.prepare(`INSERT INTO merchants (id, canonical_name, default_account_id) VALUES (?, ?, ?)`).run(id, canonical, input.default_account_id ?? null);
|
|
54
|
+
merchant = {
|
|
55
|
+
id,
|
|
56
|
+
canonical_name: canonical,
|
|
57
|
+
default_account_id: input.default_account_id ?? null,
|
|
58
|
+
created_at: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
let aliasConflict;
|
|
62
|
+
if (input.alias) {
|
|
63
|
+
const normalized = normalizeDescriptor(input.alias);
|
|
64
|
+
if (normalized) {
|
|
65
|
+
const existsAlias = db
|
|
66
|
+
.prepare(`SELECT merchant_id FROM merchant_aliases WHERE normalized_pattern = ?`)
|
|
67
|
+
.get(normalized);
|
|
68
|
+
if (!existsAlias) {
|
|
69
|
+
db.prepare(`INSERT INTO merchant_aliases (id, merchant_id, normalized_pattern) VALUES (?, ?, ?)`).run(`ma:${randomUUID()}`, merchant.id, normalized);
|
|
70
|
+
}
|
|
71
|
+
else if (existsAlias.merchant_id !== merchant.id) {
|
|
72
|
+
aliasConflict = { pattern: normalized, held_by: existsAlias.merchant_id };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return aliasConflict ? { ...merchant, alias_conflict: aliasConflict } : merchant;
|
|
77
|
+
}
|
|
78
|
+
export function findMerchantByAlias(db, rawDescriptor) {
|
|
79
|
+
const normalized = normalizeDescriptor(rawDescriptor);
|
|
80
|
+
if (!normalized)
|
|
81
|
+
return null;
|
|
82
|
+
const row = db.prepare(`SELECT m.id, m.canonical_name, m.default_account_id, m.created_at
|
|
83
|
+
FROM merchant_aliases ma
|
|
84
|
+
JOIN merchants m ON m.id = ma.merchant_id
|
|
85
|
+
WHERE ma.normalized_pattern = ?`).get(normalized);
|
|
86
|
+
if (!row)
|
|
87
|
+
return null;
|
|
88
|
+
return { merchant: row, default_account_id: row.default_account_id };
|
|
89
|
+
}
|
|
90
|
+
export function listMerchants(db, opts = {}) {
|
|
91
|
+
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1000);
|
|
92
|
+
return db.prepare(`SELECT m.id, m.canonical_name, m.default_account_id, m.created_at,
|
|
93
|
+
(SELECT COUNT(*) FROM merchant_aliases ma WHERE ma.merchant_id = m.id) AS alias_count
|
|
94
|
+
FROM merchants m
|
|
95
|
+
ORDER BY m.canonical_name
|
|
96
|
+
LIMIT ?`).all(limit);
|
|
97
|
+
}
|
|
98
|
+
export function countMerchants(db) {
|
|
99
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM merchants`).get();
|
|
100
|
+
return row.n;
|
|
101
|
+
}
|
|
102
|
+
export function merchantExists(db, id) {
|
|
103
|
+
return !!db.prepare(`SELECT 1 FROM merchants WHERE id = ?`).get(id);
|
|
104
|
+
}
|
|
105
|
+
export function findMerchantById(db, id) {
|
|
106
|
+
const row = db
|
|
107
|
+
.prepare(`SELECT id, canonical_name, default_account_id, created_at FROM merchants WHERE id = ?`)
|
|
108
|
+
.get(id);
|
|
109
|
+
return row ?? null;
|
|
110
|
+
}
|
|
111
|
+
export function setMerchantDefaultAccount(db, merchantId, accountId) {
|
|
112
|
+
const before = db
|
|
113
|
+
.prepare(`SELECT default_account_id FROM merchants WHERE id = ?`)
|
|
114
|
+
.get(merchantId);
|
|
115
|
+
if (!before)
|
|
116
|
+
throw new Error(`merchant not found: ${merchantId}`);
|
|
117
|
+
db.prepare(`UPDATE merchants SET default_account_id = ? WHERE id = ?`)
|
|
118
|
+
.run(accountId, merchantId);
|
|
119
|
+
return { before: before.default_account_id, after: accountId };
|
|
120
|
+
}
|
|
121
|
+
export function clearMerchantDefaultAccount(db, merchantId) {
|
|
122
|
+
const row = db
|
|
123
|
+
.prepare(`SELECT default_account_id FROM merchants WHERE id = ?`)
|
|
124
|
+
.get(merchantId);
|
|
125
|
+
if (!row)
|
|
126
|
+
return null;
|
|
127
|
+
db.prepare(`UPDATE merchants SET default_account_id = NULL WHERE id = ?`).run(merchantId);
|
|
128
|
+
return { before: row.default_account_id };
|
|
129
|
+
}
|
|
130
|
+
/** One transaction, so a partial merge never persists. */
|
|
131
|
+
export function mergeMerchants(db, fromId, toId) {
|
|
132
|
+
if (fromId === toId)
|
|
133
|
+
throw new Error("Cannot merge a merchant into itself.");
|
|
134
|
+
const from = findMerchantById(db, fromId);
|
|
135
|
+
if (!from)
|
|
136
|
+
throw new Error(`Source merchant ${fromId} not found.`);
|
|
137
|
+
const to = findMerchantById(db, toId);
|
|
138
|
+
if (!to)
|
|
139
|
+
throw new Error(`Destination merchant ${toId} not found.`);
|
|
140
|
+
let movedTransactions = 0;
|
|
141
|
+
let movedAliases = 0;
|
|
142
|
+
let adoptedDefaultAccount;
|
|
143
|
+
const tx = db.transaction(() => {
|
|
144
|
+
movedTransactions = db
|
|
145
|
+
.prepare(`UPDATE transactions SET merchant_id = ? WHERE merchant_id = ?`)
|
|
146
|
+
.run(toId, fromId).changes;
|
|
147
|
+
movedAliases = db
|
|
148
|
+
.prepare(`UPDATE merchant_aliases SET merchant_id = ? WHERE merchant_id = ?`)
|
|
149
|
+
.run(toId, fromId).changes;
|
|
150
|
+
if (!to.default_account_id && from.default_account_id) {
|
|
151
|
+
db.prepare(`UPDATE merchants SET default_account_id = ? WHERE id = ?`)
|
|
152
|
+
.run(from.default_account_id, toId);
|
|
153
|
+
adoptedDefaultAccount = from.default_account_id;
|
|
154
|
+
}
|
|
155
|
+
db.prepare(`DELETE FROM merchants WHERE id = ?`).run(fromId);
|
|
156
|
+
});
|
|
157
|
+
tx();
|
|
158
|
+
return {
|
|
159
|
+
moved_transactions: movedTransactions,
|
|
160
|
+
moved_aliases: movedAliases,
|
|
161
|
+
...(adoptedDefaultAccount ? { adopted_default_account: adoptedDefaultAccount } : {}),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function listNotes(db) {
|
|
2
|
+
return db.prepare(`SELECT id, content, category, created_at FROM notes ORDER BY created_at DESC`).all();
|
|
3
|
+
}
|
|
4
|
+
export function countNotes(db) {
|
|
5
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM notes`).get();
|
|
6
|
+
return row.n;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Idempotent on (category, content): a verbatim repeat is a no-op. Semantic
|
|
10
|
+
* dedup (different wording for the same note) is the calling agent's job.
|
|
11
|
+
*/
|
|
12
|
+
export function addNote(db, content, category = "fact") {
|
|
13
|
+
const existing = db
|
|
14
|
+
.prepare(`SELECT 1 FROM notes WHERE category = ? AND content = ? LIMIT 1`)
|
|
15
|
+
.get(category, content);
|
|
16
|
+
if (existing)
|
|
17
|
+
return;
|
|
18
|
+
db.prepare(`INSERT INTO notes (content, category) VALUES (?, ?)`).run(content, category);
|
|
19
|
+
}
|
|
20
|
+
export function deleteNote(db, id) {
|
|
21
|
+
const row = db
|
|
22
|
+
.prepare(`SELECT id, content, category, created_at FROM notes WHERE id = ?`)
|
|
23
|
+
.get(id);
|
|
24
|
+
if (!row)
|
|
25
|
+
return null;
|
|
26
|
+
db.prepare(`DELETE FROM notes WHERE id = ?`).run(id);
|
|
27
|
+
return row;
|
|
28
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import { parseJsonOrNull } from "../../lib/json.js";
|
|
3
|
+
/** The `cn:` id prefix is opaque - nothing else parses it. */
|
|
4
|
+
export function recordQuestion(db, input) {
|
|
5
|
+
const id = `cn:${randomUUID()}`;
|
|
6
|
+
db.prepare(`INSERT INTO questions (id, batch_id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json)
|
|
7
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.batch_id ?? null, input.file_id, input.transaction_id ?? null, input.account_id, input.kind ?? null, input.prompt, input.options ? JSON.stringify(input.options) : null, input.context ? JSON.stringify(input.context) : null);
|
|
8
|
+
if (input.transaction_id) {
|
|
9
|
+
db.prepare(`UPDATE transactions SET has_question = 1 WHERE id = ?`).run(input.transaction_id);
|
|
10
|
+
}
|
|
11
|
+
if (input.account_id) {
|
|
12
|
+
db.prepare(`UPDATE accounts SET has_question = 1 WHERE id = ?`).run(input.account_id);
|
|
13
|
+
}
|
|
14
|
+
return id;
|
|
15
|
+
}
|
|
16
|
+
/** Deletes the row outright (rather than marking it closed); null if the id doesn't exist. */
|
|
17
|
+
export function closeQuestion(db, id, answer) {
|
|
18
|
+
const row = db
|
|
19
|
+
.prepare(`SELECT prompt, kind, transaction_id, account_id, context_json FROM questions WHERE id = ?`)
|
|
20
|
+
.get(id);
|
|
21
|
+
if (!row)
|
|
22
|
+
return null;
|
|
23
|
+
db.prepare(`DELETE FROM questions WHERE id = ?`).run(id);
|
|
24
|
+
maybeClearHasQuestionFlags(db, {
|
|
25
|
+
transaction_id: row.transaction_id,
|
|
26
|
+
account_id: row.account_id,
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
prompt: row.prompt,
|
|
30
|
+
kind: row.kind,
|
|
31
|
+
answer,
|
|
32
|
+
rule_key: extractRuleKey(row.context_json),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function extractRuleKey(contextJson) {
|
|
36
|
+
const parsed = parseJsonOrNull(contextJson);
|
|
37
|
+
return typeof parsed?.rule_key === "string" ? parsed.rule_key : null;
|
|
38
|
+
}
|
|
39
|
+
// Safe to call after any resolution; idempotent.
|
|
40
|
+
function maybeClearHasQuestionFlags(db, target) {
|
|
41
|
+
if (target.transaction_id) {
|
|
42
|
+
const open = db
|
|
43
|
+
.prepare(`SELECT 1 FROM questions WHERE transaction_id = ? LIMIT 1`)
|
|
44
|
+
.get(target.transaction_id);
|
|
45
|
+
if (!open)
|
|
46
|
+
db.prepare(`UPDATE transactions SET has_question = 0 WHERE id = ?`).run(target.transaction_id);
|
|
47
|
+
}
|
|
48
|
+
if (target.account_id) {
|
|
49
|
+
const open = db
|
|
50
|
+
.prepare(`SELECT 1 FROM questions WHERE account_id = ? LIMIT 1`)
|
|
51
|
+
.get(target.account_id);
|
|
52
|
+
if (!open)
|
|
53
|
+
db.prepare(`UPDATE accounts SET has_question = 0 WHERE id = ?`).run(target.account_id);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const ACTIVE_DEFERRED_CLAUSE = "(deferred_until IS NULL OR deferred_until <= datetime('now'))";
|
|
57
|
+
export function countQuestions(db, scope = {}) {
|
|
58
|
+
const conditions = [];
|
|
59
|
+
const params = [];
|
|
60
|
+
if (scope.file_id) {
|
|
61
|
+
conditions.push("file_id = ?");
|
|
62
|
+
params.push(scope.file_id);
|
|
63
|
+
}
|
|
64
|
+
if (scope.transaction_id) {
|
|
65
|
+
conditions.push("transaction_id = ?");
|
|
66
|
+
params.push(scope.transaction_id);
|
|
67
|
+
}
|
|
68
|
+
if (scope.account_id) {
|
|
69
|
+
conditions.push("account_id = ?");
|
|
70
|
+
params.push(scope.account_id);
|
|
71
|
+
}
|
|
72
|
+
if (scope.kind) {
|
|
73
|
+
conditions.push("kind = ?");
|
|
74
|
+
params.push(scope.kind);
|
|
75
|
+
}
|
|
76
|
+
if (scope.batch_id) {
|
|
77
|
+
conditions.push("batch_id = ?");
|
|
78
|
+
params.push(scope.batch_id);
|
|
79
|
+
}
|
|
80
|
+
if (!scope.includeDeferred)
|
|
81
|
+
conditions.push(ACTIVE_DEFERRED_CLAUSE);
|
|
82
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
83
|
+
const row = db
|
|
84
|
+
.prepare(`SELECT COUNT(*) AS n FROM questions ${where}`)
|
|
85
|
+
.get(...params);
|
|
86
|
+
return row.n;
|
|
87
|
+
}
|
|
88
|
+
const ROW_COLUMNS = "id, batch_id, file_id, transaction_id, account_id, kind, prompt, options_json, context_json, deferred_until, created_at";
|
|
89
|
+
export function listQuestions(db, opts = {}) {
|
|
90
|
+
const capped = Math.min(Math.max(opts.limit ?? 200, 1), 1000);
|
|
91
|
+
const conditions = [];
|
|
92
|
+
const params = [];
|
|
93
|
+
if (opts.batchId) {
|
|
94
|
+
conditions.push("batch_id = ?");
|
|
95
|
+
params.push(opts.batchId);
|
|
96
|
+
}
|
|
97
|
+
if (!opts.includeDeferred)
|
|
98
|
+
conditions.push(ACTIVE_DEFERRED_CLAUSE);
|
|
99
|
+
const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
100
|
+
params.push(capped);
|
|
101
|
+
return db.prepare(`SELECT ${ROW_COLUMNS}
|
|
102
|
+
FROM questions
|
|
103
|
+
${where}
|
|
104
|
+
ORDER BY created_at ASC
|
|
105
|
+
LIMIT ?`).all(...params);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* `listQuestions`/`countQuestions` hide deferred rows by default until the
|
|
109
|
+
* timestamp passes; pass `includeDeferred: true` for an unfiltered view.
|
|
110
|
+
*/
|
|
111
|
+
export function deferQuestion(db, id, days) {
|
|
112
|
+
const safeDays = Math.max(1, Math.floor(days));
|
|
113
|
+
const result = db
|
|
114
|
+
.prepare(`UPDATE questions SET deferred_until = datetime('now', ?) WHERE id = ?`)
|
|
115
|
+
.run(`+${safeDays} days`, id);
|
|
116
|
+
return result.changes > 0;
|
|
117
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows sharing a non-null group_id never match each other (a salary's legs aren't
|
|
3
|
+
* duplicates); returns components of size >= 2.
|
|
4
|
+
*/
|
|
5
|
+
export function findDuplicateTransactions(db, opts = {}) {
|
|
6
|
+
const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 2));
|
|
7
|
+
const minAmount = opts.minAmount ?? 0;
|
|
8
|
+
const rows = db
|
|
9
|
+
.prepare(`SELECT t.id, t.group_id, t.date, t.description, t.amount, t.currency,
|
|
10
|
+
t.source_file_id, t.merchant_id, t.debit_account_id, t.credit_account_id,
|
|
11
|
+
da.name AS debit_account_name, ca.name AS credit_account_name
|
|
12
|
+
FROM transactions t
|
|
13
|
+
LEFT JOIN accounts da ON da.id = t.debit_account_id
|
|
14
|
+
LEFT JOIN accounts ca ON ca.id = t.credit_account_id
|
|
15
|
+
WHERE t.void_of IS NULL`)
|
|
16
|
+
.all();
|
|
17
|
+
const buckets = new Map();
|
|
18
|
+
for (const r of rows) {
|
|
19
|
+
if (r.amount < minAmount)
|
|
20
|
+
continue;
|
|
21
|
+
const key = `${r.amount}|${r.debit_account_id}|${r.credit_account_id}`;
|
|
22
|
+
const arr = buckets.get(key) ?? [];
|
|
23
|
+
arr.push(r);
|
|
24
|
+
buckets.set(key, arr);
|
|
25
|
+
}
|
|
26
|
+
const groups = [];
|
|
27
|
+
for (const bucket of buckets.values()) {
|
|
28
|
+
if (bucket.length < 2)
|
|
29
|
+
continue;
|
|
30
|
+
for (const comp of proximityComponents(bucket, toleranceDays)) {
|
|
31
|
+
if (comp.length >= 2)
|
|
32
|
+
groups.push(comp);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return groups;
|
|
36
|
+
}
|
|
37
|
+
function proximityComponents(bucket, toleranceDays) {
|
|
38
|
+
const n = bucket.length;
|
|
39
|
+
const parent = Array.from({ length: n }, (_, i) => i);
|
|
40
|
+
const find = (x) => {
|
|
41
|
+
while (parent[x] !== x) {
|
|
42
|
+
parent[x] = parent[parent[x]];
|
|
43
|
+
x = parent[x];
|
|
44
|
+
}
|
|
45
|
+
return x;
|
|
46
|
+
};
|
|
47
|
+
const union = (a, b) => {
|
|
48
|
+
const ra = find(a);
|
|
49
|
+
const rb = find(b);
|
|
50
|
+
if (ra !== rb)
|
|
51
|
+
parent[ra] = rb;
|
|
52
|
+
};
|
|
53
|
+
for (let i = 0; i < n; i++) {
|
|
54
|
+
for (let j = i + 1; j < n; j++) {
|
|
55
|
+
const a = bucket[i];
|
|
56
|
+
const b = bucket[j];
|
|
57
|
+
if (dayDiff(a.date, b.date) > toleranceDays)
|
|
58
|
+
continue;
|
|
59
|
+
if (a.group_id && b.group_id && a.group_id === b.group_id)
|
|
60
|
+
continue;
|
|
61
|
+
union(i, j);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const comps = new Map();
|
|
65
|
+
for (let i = 0; i < n; i++) {
|
|
66
|
+
const root = find(i);
|
|
67
|
+
const arr = comps.get(root) ?? [];
|
|
68
|
+
arr.push(bucket[i]);
|
|
69
|
+
comps.set(root, arr);
|
|
70
|
+
}
|
|
71
|
+
return [...comps.values()];
|
|
72
|
+
}
|
|
73
|
+
/** Whole-day distance between two ISO dates; +Infinity on unparseable input. */
|
|
74
|
+
function dayDiff(a, b) {
|
|
75
|
+
const aDate = Date.parse(a);
|
|
76
|
+
const bDate = Date.parse(b);
|
|
77
|
+
if (Number.isNaN(aDate) || Number.isNaN(bDate))
|
|
78
|
+
return Number.POSITIVE_INFINITY;
|
|
79
|
+
return Math.abs(Math.round((bDate - aDate) / 86_400_000));
|
|
80
|
+
}
|