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.
Files changed (81) hide show
  1. package/README.md +33 -43
  2. package/dist/accounts/taxonomy.d.ts +1 -1
  3. package/dist/accounts/taxonomy.js +2 -2
  4. package/dist/ai/agent.d.ts +19 -5
  5. package/dist/ai/agent.js +26 -6
  6. package/dist/ai/memory.d.ts +14 -5
  7. package/dist/ai/memory.js +12 -0
  8. package/dist/ai/personas.d.ts +11 -0
  9. package/dist/ai/personas.js +193 -0
  10. package/dist/ai/prompt-sections.d.ts +49 -0
  11. package/dist/ai/prompt-sections.js +107 -0
  12. package/dist/ai/system-prompt.d.ts +14 -3
  13. package/dist/ai/system-prompt.js +59 -165
  14. package/dist/ai/thinking.js +1 -1
  15. package/dist/ai/tools/common.js +2 -5
  16. package/dist/ai/tools/index.js +32 -7
  17. package/dist/ai/tools/ingest.d.ts +3 -1
  18. package/dist/ai/tools/ingest.js +372 -124
  19. package/dist/ai/tools/merchants.d.ts +2 -0
  20. package/dist/ai/tools/merchants.js +117 -0
  21. package/dist/ai/tools/read.js +57 -24
  22. package/dist/ai/tools/record.d.ts +2 -0
  23. package/dist/ai/tools/record.js +188 -0
  24. package/dist/ai/tools/review.d.ts +2 -0
  25. package/dist/ai/tools/review.js +359 -0
  26. package/dist/ai/tools/scan.js +5 -3
  27. package/dist/ai/tools/types.d.ts +33 -4
  28. package/dist/cli/commands/accounts.js +33 -25
  29. package/dist/cli/commands/record.d.ts +4 -0
  30. package/dist/cli/commands/record.js +119 -0
  31. package/dist/cli/commands/revert.js +1 -1
  32. package/dist/cli/commands/review.d.ts +2 -0
  33. package/dist/cli/commands/review.js +15 -0
  34. package/dist/cli/commands/scan.d.ts +4 -2
  35. package/dist/cli/commands/scan.js +143 -19
  36. package/dist/cli/commands/status.js +6 -9
  37. package/dist/cli/commands/transactions.js +36 -41
  38. package/dist/cli/format.d.ts +2 -0
  39. package/dist/cli/format.js +7 -2
  40. package/dist/cli/index.js +28 -13
  41. package/dist/cli/ink/scan_dashboard.d.ts +38 -0
  42. package/dist/cli/ink/scan_dashboard.js +62 -0
  43. package/dist/cli/setup.d.ts +0 -1
  44. package/dist/cli/setup.js +2 -8
  45. package/dist/cli/ux.d.ts +2 -1
  46. package/dist/cli/ux.js +36 -2
  47. package/dist/currency.d.ts +3 -0
  48. package/dist/currency.js +12 -1
  49. package/dist/db/queries/account_balance.d.ts +84 -4
  50. package/dist/db/queries/account_balance.js +239 -20
  51. package/dist/db/queries/action_log.d.ts +29 -0
  52. package/dist/db/queries/action_log.js +27 -0
  53. package/dist/db/queries/concerns.d.ts +50 -0
  54. package/dist/db/queries/concerns.js +91 -0
  55. package/dist/db/queries/journal.d.ts +75 -8
  56. package/dist/db/queries/journal.js +131 -19
  57. package/dist/db/queries/merchants.d.ts +42 -0
  58. package/dist/db/queries/merchants.js +120 -0
  59. package/dist/db/queries/recurrences.d.ts +33 -0
  60. package/dist/db/queries/recurrences.js +128 -0
  61. package/dist/db/queries/search.d.ts +5 -4
  62. package/dist/db/queries/search.js +16 -12
  63. package/dist/db/queries/transactions.d.ts +167 -0
  64. package/dist/db/queries/transactions.js +320 -0
  65. package/dist/db/schema.js +74 -9
  66. package/dist/reviewer/pipeline.d.ts +18 -0
  67. package/dist/reviewer/pipeline.js +46 -0
  68. package/dist/reviewer/prompts.d.ts +12 -0
  69. package/dist/reviewer/prompts.js +22 -0
  70. package/dist/scanner/account_mutex.d.ts +1 -0
  71. package/dist/scanner/account_mutex.js +16 -0
  72. package/dist/scanner/buffer.d.ts +51 -0
  73. package/dist/scanner/buffer.js +63 -0
  74. package/dist/scanner/concurrency.d.ts +14 -0
  75. package/dist/scanner/concurrency.js +31 -0
  76. package/dist/scanner/decrypt_queue.d.ts +57 -0
  77. package/dist/scanner/decrypt_queue.js +96 -0
  78. package/dist/scanner/pipeline.d.ts +47 -18
  79. package/dist/scanner/pipeline.js +247 -97
  80. package/dist/scanner/prompts.js +3 -3
  81. 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
- const entryId = `je:${randomUUID()}`;
33
- const insertHeader = db.prepare(`INSERT INTO journal_entries (id, date, description, source_file_id, source_page)
34
- VALUES (?, ?, ?, ?, ?)`);
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 tx = db.transaction(() => {
38
- insertHeader.run(entryId, entry.date, entry.description, entry.source_file_id ?? null, entry.source_page ?? null);
39
- for (const line of entry.lines) {
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
- // Small chart of accounts → a single name lookup beats GROUP_CONCAT join
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;