plasalid 0.4.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. package/README.md +15 -14
  2. package/dist/ai/agent.d.ts +15 -2
  3. package/dist/ai/agent.js +21 -2
  4. package/dist/ai/memory.d.ts +2 -0
  5. package/dist/ai/memory.js +2 -2
  6. package/dist/ai/personas.d.ts +2 -1
  7. package/dist/ai/personas.js +115 -45
  8. package/dist/ai/prompt-sections.d.ts +5 -0
  9. package/dist/ai/prompt-sections.js +26 -8
  10. package/dist/ai/system-prompt.d.ts +11 -0
  11. package/dist/ai/system-prompt.js +21 -6
  12. package/dist/ai/thinking.js +1 -1
  13. package/dist/ai/tools/common.js +2 -5
  14. package/dist/ai/tools/index.js +28 -8
  15. package/dist/ai/tools/ingest.d.ts +2 -1
  16. package/dist/ai/tools/ingest.js +262 -151
  17. package/dist/ai/tools/merchants.d.ts +2 -0
  18. package/dist/ai/tools/merchants.js +117 -0
  19. package/dist/ai/tools/read.js +31 -29
  20. package/dist/ai/tools/record.d.ts +2 -0
  21. package/dist/ai/tools/record.js +188 -0
  22. package/dist/ai/tools/review.js +77 -80
  23. package/dist/ai/tools/scan.js +1 -1
  24. package/dist/ai/tools/types.d.ts +15 -6
  25. package/dist/cli/commands/accounts.js +33 -25
  26. package/dist/cli/commands/record.d.ts +4 -0
  27. package/dist/cli/commands/record.js +119 -0
  28. package/dist/cli/commands/revert.js +1 -1
  29. package/dist/cli/commands/scan.js +15 -19
  30. package/dist/cli/commands/status.js +6 -9
  31. package/dist/cli/commands/transactions.js +36 -41
  32. package/dist/cli/format.d.ts +2 -0
  33. package/dist/cli/format.js +7 -2
  34. package/dist/cli/index.js +19 -7
  35. package/dist/cli/ink/scan_dashboard.d.ts +1 -1
  36. package/dist/cli/ink/scan_dashboard.js +2 -2
  37. package/dist/cli/setup.d.ts +0 -1
  38. package/dist/cli/setup.js +2 -8
  39. package/dist/currency.d.ts +3 -0
  40. package/dist/currency.js +12 -1
  41. package/dist/db/queries/account_balance.d.ts +83 -4
  42. package/dist/db/queries/account_balance.js +239 -20
  43. package/dist/db/queries/action_log.d.ts +29 -0
  44. package/dist/db/queries/action_log.js +27 -0
  45. package/dist/db/queries/concerns.d.ts +10 -7
  46. package/dist/db/queries/concerns.js +20 -16
  47. package/dist/db/queries/journal.d.ts +1 -0
  48. package/dist/db/queries/merchants.d.ts +42 -0
  49. package/dist/db/queries/merchants.js +120 -0
  50. package/dist/db/queries/recurrences.d.ts +3 -3
  51. package/dist/db/queries/recurrences.js +32 -34
  52. package/dist/db/queries/search.d.ts +5 -4
  53. package/dist/db/queries/search.js +16 -12
  54. package/dist/db/queries/transactions.d.ts +167 -0
  55. package/dist/db/queries/transactions.js +320 -0
  56. package/dist/db/schema.js +51 -9
  57. package/dist/reviewer/pipeline.d.ts +4 -4
  58. package/dist/reviewer/pipeline.js +4 -4
  59. package/dist/reviewer/prompts.js +4 -4
  60. package/dist/scanner/buffer.d.ts +24 -21
  61. package/dist/scanner/buffer.js +18 -18
  62. package/dist/scanner/pipeline.d.ts +3 -2
  63. package/dist/scanner/pipeline.js +33 -36
  64. package/dist/scanner/prompts.js +3 -3
  65. package/package.json +2 -2
@@ -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
+ }
@@ -6,7 +6,7 @@ export interface RecurrenceCandidate {
6
6
  amount: number;
7
7
  currency: string;
8
8
  side: "debit" | "credit";
9
- entries: {
9
+ transactions: {
10
10
  id: string;
11
11
  date: string;
12
12
  description: string;
@@ -26,8 +26,8 @@ export interface RecordRecurrenceInput {
26
26
  frequency: RecurrenceFrequency;
27
27
  amount_typical?: number | null;
28
28
  currency?: string;
29
- entry_ids: string[];
29
+ transaction_ids: string[];
30
30
  notes?: string | null;
31
31
  }
32
32
  export declare function recordRecurrence(db: Database.Database, input: RecordRecurrenceInput): string;
33
- export declare function linkEntryToRecurrence(db: Database.Database, entryId: string, recurrenceId: string): void;
33
+ export declare function linkTransactionToRecurrence(db: Database.Database, transactionId: string, recurrenceId: string): void;
@@ -1,24 +1,24 @@
1
1
  import { randomUUID } from "crypto";
2
- import { dayDiff } from "./journal.js";
2
+ import { dayDiff } from "./transactions.js";
3
3
  export function findRecurrenceCandidates(db, opts = {}) {
4
4
  const minOccurrences = Math.max(2, opts.minOccurrences ?? 3);
5
- const accountFilter = opts.accountId ? `AND jl.account_id = ?` : ``;
5
+ const accountFilter = opts.accountId ? `AND p.account_id = ?` : ``;
6
6
  const params = opts.accountId ? [opts.accountId] : [];
7
- const rows = db.prepare(`SELECT jl.account_id,
7
+ const rows = db.prepare(`SELECT p.account_id,
8
8
  a.name AS account_name,
9
- jl.currency,
10
- CASE WHEN jl.debit > 0 THEN jl.debit ELSE jl.credit END AS amount,
11
- CASE WHEN jl.debit > 0 THEN 'debit' ELSE 'credit' END AS side,
12
- je.id AS entry_id,
13
- je.date,
14
- je.description
15
- FROM journal_lines jl
16
- JOIN journal_entries je ON je.id = jl.entry_id
17
- JOIN accounts a ON a.id = jl.account_id
18
- WHERE je.recurrence_id IS NULL
19
- AND (jl.debit > 0 OR jl.credit > 0)
9
+ p.currency,
10
+ CASE WHEN p.debit > 0 THEN p.debit ELSE p.credit END AS amount,
11
+ CASE WHEN p.debit > 0 THEN 'debit' ELSE 'credit' END AS side,
12
+ t.id AS transaction_id,
13
+ t.date,
14
+ t.description
15
+ FROM postings p
16
+ JOIN transactions t ON t.id = p.transaction_id
17
+ JOIN accounts a ON a.id = p.account_id
18
+ WHERE t.recurrence_id IS NULL
19
+ AND (p.debit > 0 OR p.credit > 0)
20
20
  ${accountFilter}
21
- ORDER BY jl.account_id, jl.currency, amount, je.date`).all(...params);
21
+ ORDER BY p.account_id, p.currency, amount, t.date`).all(...params);
22
22
  const buckets = new Map();
23
23
  for (const r of rows) {
24
24
  const key = `${r.account_id}|${r.currency}|${Math.round(r.amount * 100)}|${r.side}`;
@@ -42,7 +42,7 @@ export function findRecurrenceCandidates(db, opts = {}) {
42
42
  amount: Math.round(bucket[0].amount * 100) / 100,
43
43
  currency: bucket[0].currency,
44
44
  side: bucket[0].side,
45
- entries: bucket.map(r => ({ id: r.entry_id, date: r.date, description: r.description })),
45
+ transactions: bucket.map(r => ({ id: r.transaction_id, date: r.date, description: r.description })),
46
46
  median_days_between: median,
47
47
  implied_frequency: classifyFrequency(median),
48
48
  });
@@ -74,13 +74,13 @@ const FREQ_PERIOD_DAYS = {
74
74
  annually: 365,
75
75
  };
76
76
  export function recordRecurrence(db, input) {
77
- if (!input.entry_ids || input.entry_ids.length === 0) {
78
- throw new Error("recordRecurrence requires at least one entry_id.");
77
+ if (!input.transaction_ids || input.transaction_ids.length === 0) {
78
+ throw new Error("recordRecurrence requires at least one transaction_id.");
79
79
  }
80
- const placeholders = input.entry_ids.map(() => "?").join(",");
81
- const dateRows = db.prepare(`SELECT date FROM journal_entries WHERE id IN (${placeholders}) ORDER BY date ASC`).all(...input.entry_ids);
80
+ const placeholders = input.transaction_ids.map(() => "?").join(",");
81
+ const dateRows = db.prepare(`SELECT date FROM transactions WHERE id IN (${placeholders}) ORDER BY date ASC`).all(...input.transaction_ids);
82
82
  if (dateRows.length === 0) {
83
- throw new Error("None of the supplied entry_ids exist.");
83
+ throw new Error("None of the supplied transaction_ids exist.");
84
84
  }
85
85
  const firstSeen = dateRows[0].date;
86
86
  const lastSeen = dateRows[dateRows.length - 1].date;
@@ -91,30 +91,28 @@ export function recordRecurrence(db, input) {
91
91
  (id, account_id, description, frequency, amount_typical, currency,
92
92
  first_seen_date, last_seen_date, next_expected_date, notes)
93
93
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, input.account_id, input.description, input.frequency, input.amount_typical ?? null, input.currency || "THB", firstSeen, lastSeen, nextExpected, input.notes ?? null);
94
- const updateEntry = db.prepare(`UPDATE journal_entries SET recurrence_id = ? WHERE id = ?`);
95
- for (const entryId of input.entry_ids)
96
- updateEntry.run(id, entryId);
94
+ const updateTx = db.prepare(`UPDATE transactions SET recurrence_id = ? WHERE id = ?`);
95
+ for (const transactionId of input.transaction_ids)
96
+ updateTx.run(id, transactionId);
97
97
  });
98
98
  tx();
99
99
  return id;
100
100
  }
101
- export function linkEntryToRecurrence(db, entryId, recurrenceId) {
101
+ export function linkTransactionToRecurrence(db, transactionId, recurrenceId) {
102
102
  const recurrence = db
103
103
  .prepare(`SELECT frequency FROM recurrences WHERE id = ?`)
104
104
  .get(recurrenceId);
105
105
  if (!recurrence)
106
106
  throw new Error(`Recurrence ${recurrenceId} not found.`);
107
- const entry = db
108
- .prepare(`SELECT date FROM journal_entries WHERE id = ?`)
109
- .get(entryId);
110
- if (!entry)
111
- throw new Error(`Entry ${entryId} not found.`);
107
+ const transaction = db
108
+ .prepare(`SELECT date FROM transactions WHERE id = ?`)
109
+ .get(transactionId);
110
+ if (!transaction)
111
+ throw new Error(`Transaction ${transactionId} not found.`);
112
112
  const tx = db.transaction(() => {
113
- db.prepare(`UPDATE journal_entries SET recurrence_id = ? WHERE id = ?`).run(recurrenceId, entryId);
114
- // Recompute first/last/next from the full member set so the recurrence
115
- // metadata stays in sync after every attach.
113
+ db.prepare(`UPDATE transactions SET recurrence_id = ? WHERE id = ?`).run(recurrenceId, transactionId);
116
114
  const span = db
117
- .prepare(`SELECT MIN(date) AS first, MAX(date) AS last FROM journal_entries WHERE recurrence_id = ?`)
115
+ .prepare(`SELECT MIN(date) AS first, MAX(date) AS last FROM transactions WHERE recurrence_id = ?`)
118
116
  .get(recurrenceId);
119
117
  const nextExpected = addDays(span.last, FREQ_PERIOD_DAYS[recurrence.frequency]);
120
118
  db.prepare(`UPDATE recurrences SET first_seen_date = ?, last_seen_date = ?, next_expected_date = ? WHERE id = ?`).run(span.first, span.last, nextExpected, recurrenceId);
@@ -1,7 +1,8 @@
1
1
  import type Database from "libsql";
2
- import type { JournalLineRow } from "./journal.js";
2
+ import type { PostingRow } from "./transactions.js";
3
3
  /**
4
- * Free-text search across journal entry descriptions, line memos, and account
5
- * names. Returns matching journal lines joined with account + entry metadata.
4
+ * Free-text search across transaction descriptions, posting memos, account
5
+ * names, and merchant canonical names. Returns matching postings joined with
6
+ * account + transaction + merchant metadata.
6
7
  */
7
- export declare function searchJournalLines(db: Database.Database, query: string, limit?: number): JournalLineRow[];
8
+ export declare function searchPostings(db: Database.Database, query: string, limit?: number): PostingRow[];
@@ -1,19 +1,23 @@
1
1
  /**
2
- * Free-text search across journal entry descriptions, line memos, and account
3
- * names. Returns matching journal lines joined with account + entry metadata.
2
+ * Free-text search across transaction descriptions, posting memos, account
3
+ * names, and merchant canonical names. Returns matching postings joined with
4
+ * account + transaction + merchant metadata.
4
5
  */
5
- export function searchJournalLines(db, query, limit = 30) {
6
+ export function searchPostings(db, query, limit = 30) {
6
7
  const needle = `%${query}%`;
7
8
  const capped = Math.min(Math.max(limit, 1), 200);
8
- return db.prepare(`SELECT jl.id, jl.entry_id, jl.account_id, jl.debit, jl.credit, jl.currency, jl.memo,
9
+ return db.prepare(`SELECT p.id, p.transaction_id, p.account_id, p.debit, p.credit, p.currency, p.memo,
9
10
  a.name AS account_name, a.type AS account_type,
10
- je.date AS entry_date, je.description AS entry_description
11
- FROM journal_lines jl
12
- JOIN journal_entries je ON je.id = jl.entry_id
13
- JOIN accounts a ON a.id = jl.account_id
14
- WHERE je.description LIKE ?
15
- OR jl.memo LIKE ?
11
+ t.date AS transaction_date, t.description AS transaction_description,
12
+ m.canonical_name AS merchant_name
13
+ FROM postings p
14
+ JOIN transactions t ON t.id = p.transaction_id
15
+ JOIN accounts a ON a.id = p.account_id
16
+ LEFT JOIN merchants m ON m.id = t.merchant_id
17
+ WHERE t.description LIKE ?
18
+ OR p.memo LIKE ?
16
19
  OR a.name LIKE ?
17
- ORDER BY je.date DESC, je.id DESC
18
- LIMIT ?`).all(needle, needle, needle, capped);
20
+ OR m.canonical_name LIKE ?
21
+ ORDER BY t.date DESC, t.id DESC
22
+ LIMIT ?`).all(needle, needle, needle, needle, capped);
19
23
  }
@@ -0,0 +1,167 @@
1
+ import type Database from "libsql";
2
+ import { type MerchantUpsertInput } from "./merchants.js";
3
+ export interface PostingInput {
4
+ account_id: string;
5
+ debit?: number;
6
+ credit?: number;
7
+ currency?: string;
8
+ memo?: string | null;
9
+ pii_flag?: boolean;
10
+ }
11
+ export interface TransactionInput {
12
+ /** Optional pre-assigned id. Used by the buffered-write path so concerns recorded mid-scan can reference the transaction before commit. */
13
+ id?: string;
14
+ date: string;
15
+ description: string;
16
+ source_file_id?: string | null;
17
+ source_page?: number | null;
18
+ raw_descriptor?: string | null;
19
+ merchant?: MerchantUpsertInput | null;
20
+ /** Pre-resolved merchant id (from scanner's alias pre-resolution pass). Overrides any `merchant` upsert when set. */
21
+ merchant_id?: string | null;
22
+ postings: PostingInput[];
23
+ }
24
+ export interface PostingRow {
25
+ id: string;
26
+ transaction_id: string;
27
+ account_id: string;
28
+ debit: number;
29
+ credit: number;
30
+ currency: string;
31
+ memo: string | null;
32
+ account_name?: string;
33
+ account_type?: string;
34
+ transaction_date?: string;
35
+ transaction_description?: string;
36
+ merchant_name?: string | null;
37
+ }
38
+ /**
39
+ * Insert a balanced transaction. Throws if SUM(debit) !== SUM(credit) or any
40
+ * posting both debits and credits. Transaction-wrapped: postings never land
41
+ * without a header, header never lands without postings.
42
+ */
43
+ export declare function recordTransaction(db: Database.Database, input: TransactionInput): string;
44
+ /**
45
+ * Validate balance + invariants and assign an id. Pure (no DB writes). Used by
46
+ * both `recordTransaction` and the buffered-scan commit path; the latter
47
+ * already runs inside its own transaction and must not open another.
48
+ */
49
+ export declare function validateTransaction(input: TransactionInput): TransactionInput & {
50
+ id: string;
51
+ };
52
+ /**
53
+ * Insert-only counterpart to `recordTransaction`. The caller is responsible
54
+ * for opening a transaction (or for accepting partial writes). Expects an
55
+ * already-validated input from `validateTransaction`.
56
+ */
57
+ export declare function insertTransactionRows(db: Database.Database, input: TransactionInput & {
58
+ id: string;
59
+ }): void;
60
+ export interface ListPostingsOptions {
61
+ account_id?: string;
62
+ from?: string;
63
+ to?: string;
64
+ q?: string;
65
+ limit?: number;
66
+ }
67
+ export interface UpdateTransactionFields {
68
+ date?: string;
69
+ description?: string;
70
+ source_page?: number | null;
71
+ }
72
+ export declare function updateTransaction(db: Database.Database, transactionId: string, fields: UpdateTransactionFields): number;
73
+ export interface UpdatePostingFields {
74
+ account_id?: string;
75
+ memo?: string | null;
76
+ }
77
+ /**
78
+ * Safe single-posting edits only. Refuses changes to `debit`, `credit`, or `currency`
79
+ * because those would silently break the transaction's balance — to fix amounts the
80
+ * caller must delete the transaction and record a fresh one.
81
+ */
82
+ export declare function updatePosting(db: Database.Database, postingId: string, fields: UpdatePostingFields): number;
83
+ /**
84
+ * Delete a transaction. ON DELETE CASCADE on `postings.transaction_id` removes
85
+ * the postings automatically.
86
+ */
87
+ export declare function deleteTransaction(db: Database.Database, transactionId: string): number;
88
+ export interface DuplicateGroupTransaction {
89
+ id: string;
90
+ date: string;
91
+ description: string;
92
+ amount: number;
93
+ account_ids: string[];
94
+ account_names: string[];
95
+ }
96
+ export interface FindDuplicateTransactionsOptions {
97
+ /** Days of slack when grouping by date. 0 means same-day only. Default 2. */
98
+ toleranceDays?: number;
99
+ /** Only consider transactions that have at least one posting on this account. */
100
+ accountId?: string;
101
+ /** Skip transactions whose total debit is below this value. */
102
+ minAmount?: number;
103
+ }
104
+ /**
105
+ * Heuristic duplicate finder: group transactions by (rounded total debit) and check
106
+ * pairs whose date difference is ≤ toleranceDays. Returns groups with ≥2 members.
107
+ * Each transaction carries both account_ids (for follow-up tool calls) and
108
+ * account_names (for human-readable presentation to the user).
109
+ */
110
+ export declare function findDuplicateTransactions(db: Database.Database, opts?: FindDuplicateTransactionsOptions): DuplicateGroupTransaction[][];
111
+ export declare function dayDiff(a: string, b: string): number;
112
+ export interface CorrelatedTransactionPair {
113
+ amount: number;
114
+ currency: string;
115
+ day_gap: number;
116
+ a: {
117
+ id: string;
118
+ date: string;
119
+ description: string;
120
+ account_ids: string[];
121
+ account_names: string[];
122
+ };
123
+ b: {
124
+ id: string;
125
+ date: string;
126
+ description: string;
127
+ account_ids: string[];
128
+ account_names: string[];
129
+ };
130
+ }
131
+ export interface FindCorrelatedTransactionsOptions {
132
+ from?: string;
133
+ to?: string;
134
+ /** Max day difference between paired transactions. Default 3. */
135
+ toleranceDays?: number;
136
+ /** Skip transactions below this total debit. Default 0. */
137
+ minAmount?: number;
138
+ }
139
+ /**
140
+ * Heuristic: surface pairs of transactions that look like the same money movement
141
+ * recorded against different accounts (e.g. a bank-to-card transfer that lands
142
+ * once on the bank statement and again on the card statement). Filters out
143
+ * pairs whose account-id sets overlap (those are duplicates, not correlations).
144
+ */
145
+ 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
+ export declare function listPostings(db: Database.Database, opts?: ListPostingsOptions): PostingRow[];