plasalid 0.3.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +33 -43
- package/dist/accounts/taxonomy.d.ts +1 -1
- package/dist/accounts/taxonomy.js +2 -2
- package/dist/ai/agent.d.ts +19 -5
- package/dist/ai/agent.js +26 -6
- package/dist/ai/memory.d.ts +14 -5
- package/dist/ai/memory.js +12 -0
- package/dist/ai/personas.d.ts +11 -0
- package/dist/ai/personas.js +193 -0
- package/dist/ai/prompt-sections.d.ts +49 -0
- package/dist/ai/prompt-sections.js +107 -0
- package/dist/ai/system-prompt.d.ts +14 -3
- package/dist/ai/system-prompt.js +59 -165
- package/dist/ai/thinking.js +1 -1
- package/dist/ai/tools/common.js +2 -5
- package/dist/ai/tools/index.js +32 -7
- package/dist/ai/tools/ingest.d.ts +3 -1
- package/dist/ai/tools/ingest.js +372 -124
- package/dist/ai/tools/merchants.d.ts +2 -0
- package/dist/ai/tools/merchants.js +117 -0
- package/dist/ai/tools/read.js +57 -24
- package/dist/ai/tools/record.d.ts +2 -0
- package/dist/ai/tools/record.js +188 -0
- package/dist/ai/tools/review.d.ts +2 -0
- package/dist/ai/tools/review.js +359 -0
- package/dist/ai/tools/scan.js +5 -3
- package/dist/ai/tools/types.d.ts +33 -4
- 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/review.d.ts +2 -0
- package/dist/cli/commands/review.js +15 -0
- package/dist/cli/commands/scan.d.ts +4 -2
- package/dist/cli/commands/scan.js +143 -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 +28 -13
- package/dist/cli/ink/scan_dashboard.d.ts +38 -0
- package/dist/cli/ink/scan_dashboard.js +62 -0
- package/dist/cli/setup.d.ts +0 -1
- package/dist/cli/setup.js +2 -8
- package/dist/cli/ux.d.ts +2 -1
- package/dist/cli/ux.js +36 -2
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +12 -1
- package/dist/db/queries/account_balance.d.ts +84 -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 +50 -0
- package/dist/db/queries/concerns.js +91 -0
- package/dist/db/queries/journal.d.ts +75 -8
- package/dist/db/queries/journal.js +131 -19
- package/dist/db/queries/merchants.d.ts +42 -0
- package/dist/db/queries/merchants.js +120 -0
- package/dist/db/queries/recurrences.d.ts +33 -0
- package/dist/db/queries/recurrences.js +128 -0
- 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 +74 -9
- package/dist/reviewer/pipeline.d.ts +18 -0
- package/dist/reviewer/pipeline.js +46 -0
- package/dist/reviewer/prompts.d.ts +12 -0
- package/dist/reviewer/prompts.js +22 -0
- package/dist/scanner/account_mutex.d.ts +1 -0
- package/dist/scanner/account_mutex.js +16 -0
- package/dist/scanner/buffer.d.ts +51 -0
- package/dist/scanner/buffer.js +63 -0
- package/dist/scanner/concurrency.d.ts +14 -0
- package/dist/scanner/concurrency.js +31 -0
- package/dist/scanner/decrypt_queue.d.ts +57 -0
- package/dist/scanner/decrypt_queue.js +96 -0
- package/dist/scanner/pipeline.d.ts +47 -18
- package/dist/scanner/pipeline.js +247 -97
- package/dist/scanner/prompts.js +3 -3
- package/package.json +2 -2
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Insert a new concerns row and flip the `has_concern` boolean on whichever
|
|
4
|
+
* target (transaction / account) was named. Returns the new `cn:<uuid>` id.
|
|
5
|
+
*/
|
|
6
|
+
export function recordConcern(db, input) {
|
|
7
|
+
const id = `cn:${randomUUID()}`;
|
|
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
|
+
}
|
|
12
|
+
if (input.account_id) {
|
|
13
|
+
db.prepare(`UPDATE accounts SET has_concern = 1 WHERE id = ?`).run(input.account_id);
|
|
14
|
+
}
|
|
15
|
+
return id;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Mark an existing concern as resolved with the user's answer and, if no other
|
|
19
|
+
* open concerns reference the same target, clear the target's `has_concern`
|
|
20
|
+
* flag. Returns the concern's target so callers can log or react.
|
|
21
|
+
*/
|
|
22
|
+
export function resolveConcern(db, id, answer) {
|
|
23
|
+
const target = getConcernTarget(db, id);
|
|
24
|
+
if (!target)
|
|
25
|
+
return null;
|
|
26
|
+
db.prepare(`UPDATE concerns SET answer = ?, resolved_at = datetime('now') WHERE id = ?`).run(answer, id);
|
|
27
|
+
maybeClearHasConcernFlags(db, target);
|
|
28
|
+
return target;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Look up the transaction/account a concern is attached to. Returns null when
|
|
32
|
+
* the concern id doesn't exist.
|
|
33
|
+
*/
|
|
34
|
+
export function getConcernTarget(db, id) {
|
|
35
|
+
const row = db
|
|
36
|
+
.prepare(`SELECT transaction_id, account_id FROM concerns WHERE id = ?`)
|
|
37
|
+
.get(id);
|
|
38
|
+
return row ?? null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Clear `has_concern` on the named transaction / account if no other open concerns
|
|
42
|
+
* still reference it. Safe to call after any concern resolution; idempotent.
|
|
43
|
+
*/
|
|
44
|
+
export function maybeClearHasConcernFlags(db, target) {
|
|
45
|
+
if (target.transaction_id) {
|
|
46
|
+
const open = db
|
|
47
|
+
.prepare(`SELECT 1 FROM concerns WHERE transaction_id = ? AND resolved_at IS NULL LIMIT 1`)
|
|
48
|
+
.get(target.transaction_id);
|
|
49
|
+
if (!open)
|
|
50
|
+
db.prepare(`UPDATE transactions SET has_concern = 0 WHERE id = ?`).run(target.transaction_id);
|
|
51
|
+
}
|
|
52
|
+
if (target.account_id) {
|
|
53
|
+
const open = db
|
|
54
|
+
.prepare(`SELECT 1 FROM concerns WHERE account_id = ? AND resolved_at IS NULL LIMIT 1`)
|
|
55
|
+
.get(target.account_id);
|
|
56
|
+
if (!open)
|
|
57
|
+
db.prepare(`UPDATE accounts SET has_concern = 0 WHERE id = ?`).run(target.account_id);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export function countOpenConcerns(db, scope = {}) {
|
|
61
|
+
const conditions = ["resolved_at IS NULL"];
|
|
62
|
+
const params = [];
|
|
63
|
+
if (scope.file_id) {
|
|
64
|
+
conditions.push("file_id = ?");
|
|
65
|
+
params.push(scope.file_id);
|
|
66
|
+
}
|
|
67
|
+
if (scope.transaction_id) {
|
|
68
|
+
conditions.push("transaction_id = ?");
|
|
69
|
+
params.push(scope.transaction_id);
|
|
70
|
+
}
|
|
71
|
+
if (scope.account_id) {
|
|
72
|
+
conditions.push("account_id = ?");
|
|
73
|
+
params.push(scope.account_id);
|
|
74
|
+
}
|
|
75
|
+
if (scope.kind) {
|
|
76
|
+
conditions.push("kind = ?");
|
|
77
|
+
params.push(scope.kind);
|
|
78
|
+
}
|
|
79
|
+
const row = db
|
|
80
|
+
.prepare(`SELECT COUNT(*) AS n FROM concerns WHERE ${conditions.join(" AND ")}`)
|
|
81
|
+
.get(...params);
|
|
82
|
+
return row.n;
|
|
83
|
+
}
|
|
84
|
+
export function listOpenConcerns(db, limit = 50) {
|
|
85
|
+
const capped = Math.min(Math.max(limit, 1), 200);
|
|
86
|
+
return db.prepare(`SELECT id, file_id, transaction_id, account_id, kind, prompt, options_json, created_at
|
|
87
|
+
FROM concerns
|
|
88
|
+
WHERE resolved_at IS NULL
|
|
89
|
+
ORDER BY created_at ASC
|
|
90
|
+
LIMIT ?`).all(capped);
|
|
91
|
+
}
|
|
@@ -8,20 +8,14 @@ export interface JournalLineInput {
|
|
|
8
8
|
pii_flag?: boolean;
|
|
9
9
|
}
|
|
10
10
|
export interface JournalEntryInput {
|
|
11
|
+
/** Optional pre-assigned id. Used by the buffered-write path so concerns recorded mid-scan can reference the entry before commit. */
|
|
12
|
+
id?: string;
|
|
11
13
|
date: string;
|
|
12
14
|
description: string;
|
|
13
15
|
source_file_id?: string | null;
|
|
14
16
|
source_page?: number | null;
|
|
15
17
|
lines: JournalLineInput[];
|
|
16
18
|
}
|
|
17
|
-
export interface JournalEntryRow {
|
|
18
|
-
id: string;
|
|
19
|
-
date: string;
|
|
20
|
-
description: string;
|
|
21
|
-
source_file_id: string | null;
|
|
22
|
-
source_page: number | null;
|
|
23
|
-
created_at: string;
|
|
24
|
-
}
|
|
25
19
|
export interface JournalLineRow {
|
|
26
20
|
id: string;
|
|
27
21
|
entry_id: string;
|
|
@@ -41,6 +35,22 @@ export interface JournalLineRow {
|
|
|
41
35
|
* a header, header never lands without lines.
|
|
42
36
|
*/
|
|
43
37
|
export declare function recordJournalEntry(db: Database.Database, entry: JournalEntryInput): string;
|
|
38
|
+
/**
|
|
39
|
+
* Validate balance + invariants and assign an id. Pure (no DB writes). Used by
|
|
40
|
+
* both `recordJournalEntry` and the buffered-scan commit path; the latter
|
|
41
|
+
* already runs inside its own transaction and must not open another.
|
|
42
|
+
*/
|
|
43
|
+
export declare function validateJournalEntry(entry: JournalEntryInput): JournalEntryInput & {
|
|
44
|
+
id: string;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Insert-only counterpart to `recordJournalEntry`. The caller is responsible
|
|
48
|
+
* for opening a transaction (or for accepting partial writes). Expects an
|
|
49
|
+
* already-validated entry from `validateJournalEntry`.
|
|
50
|
+
*/
|
|
51
|
+
export declare function insertJournalEntryRows(db: Database.Database, entry: JournalEntryInput & {
|
|
52
|
+
id: string;
|
|
53
|
+
}): void;
|
|
44
54
|
export interface ListJournalLinesOptions {
|
|
45
55
|
account_id?: string;
|
|
46
56
|
from?: string;
|
|
@@ -92,4 +102,61 @@ export interface FindDuplicateEntriesOptions {
|
|
|
92
102
|
* account_names (for human-readable presentation to the user).
|
|
93
103
|
*/
|
|
94
104
|
export declare function findDuplicateEntries(db: Database.Database, opts?: FindDuplicateEntriesOptions): DuplicateGroupEntry[][];
|
|
105
|
+
export declare function dayDiff(a: string, b: string): number;
|
|
106
|
+
/** Correlations */
|
|
107
|
+
export interface CorrelatedEntryPair {
|
|
108
|
+
amount: number;
|
|
109
|
+
currency: string;
|
|
110
|
+
day_gap: number;
|
|
111
|
+
a: {
|
|
112
|
+
id: string;
|
|
113
|
+
date: string;
|
|
114
|
+
description: string;
|
|
115
|
+
account_ids: string[];
|
|
116
|
+
account_names: string[];
|
|
117
|
+
};
|
|
118
|
+
b: {
|
|
119
|
+
id: string;
|
|
120
|
+
date: string;
|
|
121
|
+
description: string;
|
|
122
|
+
account_ids: string[];
|
|
123
|
+
account_names: string[];
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
export interface FindCorrelatedEntriesOptions {
|
|
127
|
+
from?: string;
|
|
128
|
+
to?: string;
|
|
129
|
+
/** Max day difference between paired entries. Default 3. */
|
|
130
|
+
toleranceDays?: number;
|
|
131
|
+
/** Skip entries below this total debit. Default 0. */
|
|
132
|
+
minAmount?: number;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Heuristic: surface pairs of entries that look like the same money movement
|
|
136
|
+
* recorded against different accounts (e.g. a bank-to-card transfer that lands
|
|
137
|
+
* once on the bank statement and again on the card statement). Filters out
|
|
138
|
+
* pairs whose account-id sets overlap (those are duplicates, not correlations).
|
|
139
|
+
*/
|
|
140
|
+
export declare function findCorrelatedEntries(db: Database.Database, opts?: FindCorrelatedEntriesOptions): CorrelatedEntryPair[];
|
|
141
|
+
export interface CorrelationCandidate {
|
|
142
|
+
id: string;
|
|
143
|
+
date: string;
|
|
144
|
+
description: string;
|
|
145
|
+
amount: number;
|
|
146
|
+
currency: string;
|
|
147
|
+
account_ids: string[];
|
|
148
|
+
account_names: string[];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Pure pair-finder: given an array of candidates already filtered by amount
|
|
152
|
+
* and equipped with account_ids/names, return the cross-pairs that look like
|
|
153
|
+
* the same money movement on different accounts (date within toleranceDays,
|
|
154
|
+
* same amount + currency, non-overlapping account sets).
|
|
155
|
+
*
|
|
156
|
+
* Used by the DB-backed `findCorrelatedEntries` and by the scan-time
|
|
157
|
+
* coordinator that runs over buffered, not-yet-committed entries.
|
|
158
|
+
*/
|
|
159
|
+
export declare function correlatePairs(entries: CorrelationCandidate[], opts?: {
|
|
160
|
+
toleranceDays?: number;
|
|
161
|
+
}): CorrelatedEntryPair[];
|
|
95
162
|
export declare function listJournalLines(db: Database.Database, opts?: ListJournalLinesOptions): JournalLineRow[];
|
|
@@ -6,6 +6,17 @@ const TOLERANCE = 0.005;
|
|
|
6
6
|
* a header, header never lands without lines.
|
|
7
7
|
*/
|
|
8
8
|
export function recordJournalEntry(db, entry) {
|
|
9
|
+
const validated = validateJournalEntry(entry);
|
|
10
|
+
const tx = db.transaction(() => { insertJournalEntryRows(db, validated); });
|
|
11
|
+
tx();
|
|
12
|
+
return validated.id;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Validate balance + invariants and assign an id. Pure (no DB writes). Used by
|
|
16
|
+
* both `recordJournalEntry` and the buffered-scan commit path; the latter
|
|
17
|
+
* already runs inside its own transaction and must not open another.
|
|
18
|
+
*/
|
|
19
|
+
export function validateJournalEntry(entry) {
|
|
9
20
|
if (!entry.lines || entry.lines.length < 2) {
|
|
10
21
|
throw new Error("Journal entry must contain at least two lines.");
|
|
11
22
|
}
|
|
@@ -29,19 +40,21 @@ export function recordJournalEntry(db, entry) {
|
|
|
29
40
|
if (Math.abs(debitTotal - creditTotal) > TOLERANCE) {
|
|
30
41
|
throw new Error(`Journal entry does not balance: debits ${debitTotal.toFixed(2)} vs credits ${creditTotal.toFixed(2)}.`);
|
|
31
42
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
return { ...entry, id: entry.id ?? `je:${randomUUID()}` };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Insert-only counterpart to `recordJournalEntry`. The caller is responsible
|
|
47
|
+
* for opening a transaction (or for accepting partial writes). Expects an
|
|
48
|
+
* already-validated entry from `validateJournalEntry`.
|
|
49
|
+
*/
|
|
50
|
+
export function insertJournalEntryRows(db, entry) {
|
|
51
|
+
db.prepare(`INSERT INTO journal_entries (id, date, description, source_file_id, source_page)
|
|
52
|
+
VALUES (?, ?, ?, ?, ?)`).run(entry.id, entry.date, entry.description, entry.source_file_id ?? null, entry.source_page ?? null);
|
|
35
53
|
const insertLine = db.prepare(`INSERT INTO journal_lines (id, entry_id, account_id, debit, credit, currency, memo, pii_flag)
|
|
36
54
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
insertLine.run(`jl:${randomUUID()}`, entryId, line.account_id, line.debit ?? 0, line.credit ?? 0, line.currency || "THB", line.memo ?? null, line.pii_flag ? 1 : 0);
|
|
41
|
-
}
|
|
42
|
-
});
|
|
43
|
-
tx();
|
|
44
|
-
return entryId;
|
|
55
|
+
for (const line of entry.lines) {
|
|
56
|
+
insertLine.run(`jl:${randomUUID()}`, entry.id, line.account_id, line.debit ?? 0, line.credit ?? 0, line.currency || "THB", line.memo ?? null, line.pii_flag ? 1 : 0);
|
|
57
|
+
}
|
|
45
58
|
}
|
|
46
59
|
export function updateJournalEntry(db, entryId, fields) {
|
|
47
60
|
const sets = [];
|
|
@@ -104,13 +117,7 @@ export function findDuplicateEntries(db, opts = {}) {
|
|
|
104
117
|
? `WHERE je.id IN (SELECT entry_id FROM journal_lines WHERE account_id = ?)`
|
|
105
118
|
: ``;
|
|
106
119
|
const params = opts.accountId ? [opts.accountId] : [];
|
|
107
|
-
|
|
108
|
-
// hacks (account names can contain commas which break a comma-separated
|
|
109
|
-
// concat, and SQLite's GROUP_CONCAT has no robust escape mechanism).
|
|
110
|
-
const nameById = new Map();
|
|
111
|
-
for (const row of db.prepare(`SELECT id, name FROM accounts`).all()) {
|
|
112
|
-
nameById.set(row.id, row.name);
|
|
113
|
-
}
|
|
120
|
+
const nameById = loadAccountNames(db);
|
|
114
121
|
const rows = db.prepare(`SELECT je.id, je.date, je.description,
|
|
115
122
|
COALESCE(SUM(jl.debit), 0) AS amount,
|
|
116
123
|
GROUP_CONCAT(jl.account_id) AS account_ids
|
|
@@ -164,13 +171,118 @@ export function findDuplicateEntries(db, opts = {}) {
|
|
|
164
171
|
}
|
|
165
172
|
return groups;
|
|
166
173
|
}
|
|
167
|
-
function dayDiff(a, b) {
|
|
174
|
+
export function dayDiff(a, b) {
|
|
168
175
|
const aDate = Date.parse(a);
|
|
169
176
|
const bDate = Date.parse(b);
|
|
170
177
|
if (Number.isNaN(aDate) || Number.isNaN(bDate))
|
|
171
178
|
return Number.POSITIVE_INFINITY;
|
|
172
179
|
return Math.abs(Math.round((bDate - aDate) / 86_400_000));
|
|
173
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Load all account id → name pairs into an in-memory map. Cheap on the small
|
|
183
|
+
* charts of accounts Plasalid deals with, and avoids GROUP_CONCAT join hacks
|
|
184
|
+
* (account names can contain commas which break a comma-separated concat, and
|
|
185
|
+
* SQLite's GROUP_CONCAT has no robust escape mechanism).
|
|
186
|
+
*/
|
|
187
|
+
function loadAccountNames(db) {
|
|
188
|
+
const map = new Map();
|
|
189
|
+
for (const row of db.prepare(`SELECT id, name FROM accounts`).all()) {
|
|
190
|
+
map.set(row.id, row.name);
|
|
191
|
+
}
|
|
192
|
+
return map;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Heuristic: surface pairs of entries that look like the same money movement
|
|
196
|
+
* recorded against different accounts (e.g. a bank-to-card transfer that lands
|
|
197
|
+
* once on the bank statement and again on the card statement). Filters out
|
|
198
|
+
* pairs whose account-id sets overlap (those are duplicates, not correlations).
|
|
199
|
+
*/
|
|
200
|
+
export function findCorrelatedEntries(db, opts = {}) {
|
|
201
|
+
const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
|
|
202
|
+
const minAmount = opts.minAmount ?? 0;
|
|
203
|
+
const dateFilter = [];
|
|
204
|
+
const params = [];
|
|
205
|
+
if (opts.from) {
|
|
206
|
+
dateFilter.push("je.date >= ?");
|
|
207
|
+
params.push(opts.from);
|
|
208
|
+
}
|
|
209
|
+
if (opts.to) {
|
|
210
|
+
dateFilter.push("je.date <= ?");
|
|
211
|
+
params.push(opts.to);
|
|
212
|
+
}
|
|
213
|
+
const where = dateFilter.length ? `WHERE ${dateFilter.join(" AND ")}` : "";
|
|
214
|
+
const nameById = loadAccountNames(db);
|
|
215
|
+
const rows = db.prepare(`SELECT je.id, je.date, je.description,
|
|
216
|
+
COALESCE(SUM(jl.debit), 0) AS amount,
|
|
217
|
+
COALESCE(MAX(jl.currency), 'THB') AS currency,
|
|
218
|
+
GROUP_CONCAT(jl.account_id) AS account_ids
|
|
219
|
+
FROM journal_entries je
|
|
220
|
+
LEFT JOIN journal_lines jl ON jl.entry_id = je.id
|
|
221
|
+
${where}
|
|
222
|
+
GROUP BY je.id`).all(...params);
|
|
223
|
+
const entries = rows
|
|
224
|
+
.filter(r => r.amount >= minAmount)
|
|
225
|
+
.map(r => {
|
|
226
|
+
const ids = (r.account_ids ?? "").split(",").filter(Boolean);
|
|
227
|
+
return {
|
|
228
|
+
id: r.id,
|
|
229
|
+
date: r.date,
|
|
230
|
+
description: r.description,
|
|
231
|
+
amount: Math.round(r.amount * 100) / 100,
|
|
232
|
+
currency: r.currency || "THB",
|
|
233
|
+
account_ids: ids,
|
|
234
|
+
account_names: ids.map(id => nameById.get(id) ?? id),
|
|
235
|
+
};
|
|
236
|
+
});
|
|
237
|
+
return correlatePairs(entries, { toleranceDays });
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* Pure pair-finder: given an array of candidates already filtered by amount
|
|
241
|
+
* and equipped with account_ids/names, return the cross-pairs that look like
|
|
242
|
+
* the same money movement on different accounts (date within toleranceDays,
|
|
243
|
+
* same amount + currency, non-overlapping account sets).
|
|
244
|
+
*
|
|
245
|
+
* Used by the DB-backed `findCorrelatedEntries` and by the scan-time
|
|
246
|
+
* coordinator that runs over buffered, not-yet-committed entries.
|
|
247
|
+
*/
|
|
248
|
+
export function correlatePairs(entries, opts = {}) {
|
|
249
|
+
const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
|
|
250
|
+
// Bucket by (amount-cents, currency) so we only compare entries that could
|
|
251
|
+
// plausibly pair. O(n) bucketing + O(k²) per bucket dominates only when many
|
|
252
|
+
// entries share the same amount.
|
|
253
|
+
const buckets = new Map();
|
|
254
|
+
for (const e of entries) {
|
|
255
|
+
const key = `${Math.round(e.amount * 100)}|${e.currency}`;
|
|
256
|
+
const arr = buckets.get(key) ?? [];
|
|
257
|
+
arr.push(e);
|
|
258
|
+
buckets.set(key, arr);
|
|
259
|
+
}
|
|
260
|
+
const pairs = [];
|
|
261
|
+
for (const bucket of buckets.values()) {
|
|
262
|
+
if (bucket.length < 2)
|
|
263
|
+
continue;
|
|
264
|
+
bucket.sort((x, y) => x.date.localeCompare(y.date));
|
|
265
|
+
for (let i = 0; i < bucket.length; i++) {
|
|
266
|
+
for (let j = i + 1; j < bucket.length; j++) {
|
|
267
|
+
const a = bucket[i], b = bucket[j];
|
|
268
|
+
const gap = dayDiff(a.date, b.date);
|
|
269
|
+
if (gap > toleranceDays)
|
|
270
|
+
break; // bucket is sorted by date
|
|
271
|
+
const overlap = a.account_ids.some(id => b.account_ids.includes(id));
|
|
272
|
+
if (overlap)
|
|
273
|
+
continue;
|
|
274
|
+
pairs.push({
|
|
275
|
+
amount: a.amount,
|
|
276
|
+
currency: a.currency,
|
|
277
|
+
day_gap: gap,
|
|
278
|
+
a: { id: a.id, date: a.date, description: a.description, account_ids: a.account_ids, account_names: a.account_names },
|
|
279
|
+
b: { id: b.id, date: b.date, description: b.description, account_ids: b.account_ids, account_names: b.account_names },
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return pairs;
|
|
285
|
+
}
|
|
174
286
|
export function listJournalLines(db, opts = {}) {
|
|
175
287
|
const conditions = [];
|
|
176
288
|
const params = [];
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Strip the noise that PDF descriptors carry on top of a merchant identity:
|
|
4
|
+
* trailing store ids, terminal codes, city tags, transaction-type words. The
|
|
5
|
+
* normalized form is what `merchant_aliases.normalized_pattern` indexes, so
|
|
6
|
+
* "STARBUCKS #1234 BKK CHARGE" and "Starbucks #5678 BANGKOK" collapse to the
|
|
7
|
+
* same alias "starbucks".
|
|
8
|
+
*/
|
|
9
|
+
const NOISE_TOKENS = new Set([
|
|
10
|
+
"bkk", "bangkok", "thailand", "th", "tha",
|
|
11
|
+
"charge", "purchase", "payment", "pmt", "ref", "txn", "trx", "tx",
|
|
12
|
+
"pos", "atm", "online", "web", "mobile", "app",
|
|
13
|
+
"co", "ltd", "company", "inc", "llc", "plc", "intl",
|
|
14
|
+
]);
|
|
15
|
+
export function normalizeDescriptor(raw) {
|
|
16
|
+
if (!raw)
|
|
17
|
+
return "";
|
|
18
|
+
const lowered = raw.toLowerCase();
|
|
19
|
+
const stripped = lowered
|
|
20
|
+
.replace(/[#*][a-z0-9]+/gi, " ")
|
|
21
|
+
.replace(/\b\d{2,}\b/g, " ")
|
|
22
|
+
.replace(/[^a-z0-9\s]+/g, " ")
|
|
23
|
+
.replace(/\s+/g, " ")
|
|
24
|
+
.trim();
|
|
25
|
+
if (!stripped)
|
|
26
|
+
return "";
|
|
27
|
+
const tokens = stripped.split(" ").filter(t => t.length > 1 && !NOISE_TOKENS.has(t));
|
|
28
|
+
if (tokens.length === 0)
|
|
29
|
+
return stripped;
|
|
30
|
+
return tokens.join(" ");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Upsert a merchant by canonical_name. Optionally upsert an alias and update
|
|
34
|
+
* the cached default_account_id. Idempotent: re-running with the same inputs
|
|
35
|
+
* does not duplicate rows. Designed to be called inside the same DB transaction
|
|
36
|
+
* as the posting writes so a transaction never lands without its merchant.
|
|
37
|
+
*/
|
|
38
|
+
export function upsertMerchant(db, input) {
|
|
39
|
+
const canonical = input.canonical_name.trim();
|
|
40
|
+
if (!canonical) {
|
|
41
|
+
throw new Error("merchant canonical_name is required");
|
|
42
|
+
}
|
|
43
|
+
const existing = db
|
|
44
|
+
.prepare(`SELECT id, canonical_name, default_account_id, created_at FROM merchants WHERE canonical_name = ?`)
|
|
45
|
+
.get(canonical);
|
|
46
|
+
let merchant;
|
|
47
|
+
if (existing) {
|
|
48
|
+
merchant = existing;
|
|
49
|
+
if (input.default_account_id && input.default_account_id !== existing.default_account_id) {
|
|
50
|
+
db.prepare(`UPDATE merchants SET default_account_id = ? WHERE id = ?`)
|
|
51
|
+
.run(input.default_account_id, existing.id);
|
|
52
|
+
merchant = { ...existing, default_account_id: input.default_account_id };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const id = `m:${randomUUID()}`;
|
|
57
|
+
db.prepare(`INSERT INTO merchants (id, canonical_name, default_account_id) VALUES (?, ?, ?)`).run(id, canonical, input.default_account_id ?? null);
|
|
58
|
+
merchant = {
|
|
59
|
+
id,
|
|
60
|
+
canonical_name: canonical,
|
|
61
|
+
default_account_id: input.default_account_id ?? null,
|
|
62
|
+
created_at: new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
if (input.alias) {
|
|
66
|
+
const normalized = normalizeDescriptor(input.alias);
|
|
67
|
+
if (normalized) {
|
|
68
|
+
const existsAlias = db
|
|
69
|
+
.prepare(`SELECT id FROM merchant_aliases WHERE normalized_pattern = ?`)
|
|
70
|
+
.get(normalized);
|
|
71
|
+
if (!existsAlias) {
|
|
72
|
+
db.prepare(`INSERT INTO merchant_aliases (id, merchant_id, normalized_pattern) VALUES (?, ?, ?)`).run(`ma:${randomUUID()}`, merchant.id, normalized);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return merchant;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolve a raw PDF descriptor to a known merchant via the alias table.
|
|
80
|
+
* Returns null if no alias matches. The scanner uses this in its pre-resolution
|
|
81
|
+
* pass so the LLM can skip re-categorizing already-seen merchants.
|
|
82
|
+
*/
|
|
83
|
+
export function findMerchantByAlias(db, rawDescriptor) {
|
|
84
|
+
const normalized = normalizeDescriptor(rawDescriptor);
|
|
85
|
+
if (!normalized)
|
|
86
|
+
return null;
|
|
87
|
+
const row = db.prepare(`SELECT m.id, m.canonical_name, m.default_account_id, m.created_at
|
|
88
|
+
FROM merchant_aliases ma
|
|
89
|
+
JOIN merchants m ON m.id = ma.merchant_id
|
|
90
|
+
WHERE ma.normalized_pattern = ?`).get(normalized);
|
|
91
|
+
if (!row)
|
|
92
|
+
return null;
|
|
93
|
+
return { merchant: row, default_account_id: row.default_account_id };
|
|
94
|
+
}
|
|
95
|
+
export function listMerchants(db, opts = {}) {
|
|
96
|
+
const where = opts.withDefaultOnly ? `WHERE m.default_account_id IS NOT NULL` : ``;
|
|
97
|
+
const limit = Math.min(Math.max(opts.limit ?? 200, 1), 1000);
|
|
98
|
+
return db.prepare(`SELECT m.id, m.canonical_name, m.default_account_id, m.created_at,
|
|
99
|
+
(SELECT COUNT(*) FROM merchant_aliases ma WHERE ma.merchant_id = m.id) AS alias_count
|
|
100
|
+
FROM merchants m
|
|
101
|
+
${where}
|
|
102
|
+
ORDER BY m.canonical_name
|
|
103
|
+
LIMIT ?`).all(limit);
|
|
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
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
export type RecurrenceFrequency = "weekly" | "biweekly" | "monthly" | "annually";
|
|
3
|
+
export interface RecurrenceCandidate {
|
|
4
|
+
account_id: string;
|
|
5
|
+
account_name: string;
|
|
6
|
+
amount: number;
|
|
7
|
+
currency: string;
|
|
8
|
+
side: "debit" | "credit";
|
|
9
|
+
transactions: {
|
|
10
|
+
id: string;
|
|
11
|
+
date: string;
|
|
12
|
+
description: string;
|
|
13
|
+
}[];
|
|
14
|
+
median_days_between: number;
|
|
15
|
+
implied_frequency: RecurrenceFrequency | "irregular";
|
|
16
|
+
}
|
|
17
|
+
export interface FindRecurrencesOptions {
|
|
18
|
+
accountId?: string;
|
|
19
|
+
/** Minimum sightings to qualify. Default 3. */
|
|
20
|
+
minOccurrences?: number;
|
|
21
|
+
}
|
|
22
|
+
export declare function findRecurrenceCandidates(db: Database.Database, opts?: FindRecurrencesOptions): RecurrenceCandidate[];
|
|
23
|
+
export interface RecordRecurrenceInput {
|
|
24
|
+
account_id: string;
|
|
25
|
+
description: string;
|
|
26
|
+
frequency: RecurrenceFrequency;
|
|
27
|
+
amount_typical?: number | null;
|
|
28
|
+
currency?: string;
|
|
29
|
+
transaction_ids: string[];
|
|
30
|
+
notes?: string | null;
|
|
31
|
+
}
|
|
32
|
+
export declare function recordRecurrence(db: Database.Database, input: RecordRecurrenceInput): string;
|
|
33
|
+
export declare function linkTransactionToRecurrence(db: Database.Database, transactionId: string, recurrenceId: string): void;
|