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,128 @@
1
+ import { randomUUID } from "crypto";
2
+ import { dayDiff } from "./transactions.js";
3
+ export function findRecurrenceCandidates(db, opts = {}) {
4
+ const minOccurrences = Math.max(2, opts.minOccurrences ?? 3);
5
+ const accountFilter = opts.accountId ? `AND p.account_id = ?` : ``;
6
+ const params = opts.accountId ? [opts.accountId] : [];
7
+ const rows = db.prepare(`SELECT p.account_id,
8
+ a.name AS account_name,
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
+ ${accountFilter}
21
+ ORDER BY p.account_id, p.currency, amount, t.date`).all(...params);
22
+ const buckets = new Map();
23
+ for (const r of rows) {
24
+ const key = `${r.account_id}|${r.currency}|${Math.round(r.amount * 100)}|${r.side}`;
25
+ const arr = buckets.get(key) ?? [];
26
+ arr.push(r);
27
+ buckets.set(key, arr);
28
+ }
29
+ const candidates = [];
30
+ for (const bucket of buckets.values()) {
31
+ if (bucket.length < minOccurrences)
32
+ continue;
33
+ const dates = bucket.map(r => r.date);
34
+ const diffs = [];
35
+ for (let i = 1; i < dates.length; i++) {
36
+ diffs.push(dayDiff(dates[i - 1], dates[i]));
37
+ }
38
+ const median = medianOf(diffs);
39
+ candidates.push({
40
+ account_id: bucket[0].account_id,
41
+ account_name: bucket[0].account_name,
42
+ amount: Math.round(bucket[0].amount * 100) / 100,
43
+ currency: bucket[0].currency,
44
+ side: bucket[0].side,
45
+ transactions: bucket.map(r => ({ id: r.transaction_id, date: r.date, description: r.description })),
46
+ median_days_between: median,
47
+ implied_frequency: classifyFrequency(median),
48
+ });
49
+ }
50
+ return candidates;
51
+ }
52
+ function medianOf(values) {
53
+ if (values.length === 0)
54
+ return 0;
55
+ const sorted = [...values].sort((a, b) => a - b);
56
+ const mid = Math.floor(sorted.length / 2);
57
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
58
+ }
59
+ function classifyFrequency(medianDays) {
60
+ if (medianDays >= 6 && medianDays <= 8)
61
+ return "weekly";
62
+ if (medianDays >= 13 && medianDays <= 15)
63
+ return "biweekly";
64
+ if (medianDays >= 27 && medianDays <= 32)
65
+ return "monthly";
66
+ if (medianDays >= 360 && medianDays <= 370)
67
+ return "annually";
68
+ return "irregular";
69
+ }
70
+ const FREQ_PERIOD_DAYS = {
71
+ weekly: 7,
72
+ biweekly: 14,
73
+ monthly: 30,
74
+ annually: 365,
75
+ };
76
+ export function recordRecurrence(db, input) {
77
+ if (!input.transaction_ids || input.transaction_ids.length === 0) {
78
+ throw new Error("recordRecurrence requires at least one transaction_id.");
79
+ }
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
+ if (dateRows.length === 0) {
83
+ throw new Error("None of the supplied transaction_ids exist.");
84
+ }
85
+ const firstSeen = dateRows[0].date;
86
+ const lastSeen = dateRows[dateRows.length - 1].date;
87
+ const nextExpected = addDays(lastSeen, FREQ_PERIOD_DAYS[input.frequency]);
88
+ const id = `rc:${randomUUID()}`;
89
+ const tx = db.transaction(() => {
90
+ db.prepare(`INSERT INTO recurrences
91
+ (id, account_id, description, frequency, amount_typical, currency,
92
+ first_seen_date, last_seen_date, next_expected_date, notes)
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 updateTx = db.prepare(`UPDATE transactions SET recurrence_id = ? WHERE id = ?`);
95
+ for (const transactionId of input.transaction_ids)
96
+ updateTx.run(id, transactionId);
97
+ });
98
+ tx();
99
+ return id;
100
+ }
101
+ export function linkTransactionToRecurrence(db, transactionId, recurrenceId) {
102
+ const recurrence = db
103
+ .prepare(`SELECT frequency FROM recurrences WHERE id = ?`)
104
+ .get(recurrenceId);
105
+ if (!recurrence)
106
+ throw new Error(`Recurrence ${recurrenceId} 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
+ const tx = db.transaction(() => {
113
+ db.prepare(`UPDATE transactions SET recurrence_id = ? WHERE id = ?`).run(recurrenceId, transactionId);
114
+ const span = db
115
+ .prepare(`SELECT MIN(date) AS first, MAX(date) AS last FROM transactions WHERE recurrence_id = ?`)
116
+ .get(recurrenceId);
117
+ const nextExpected = addDays(span.last, FREQ_PERIOD_DAYS[recurrence.frequency]);
118
+ db.prepare(`UPDATE recurrences SET first_seen_date = ?, last_seen_date = ?, next_expected_date = ? WHERE id = ?`).run(span.first, span.last, nextExpected, recurrenceId);
119
+ });
120
+ tx();
121
+ }
122
+ function addDays(dateIso, days) {
123
+ const t = Date.parse(dateIso);
124
+ if (Number.isNaN(t))
125
+ return dateIso;
126
+ const next = new Date(t + days * 86_400_000);
127
+ return next.toISOString().slice(0, 10);
128
+ }
@@ -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[];
@@ -0,0 +1,320 @@
1
+ import { randomUUID } from "crypto";
2
+ import { upsertMerchant } from "./merchants.js";
3
+ const TOLERANCE = 0.005;
4
+ /**
5
+ * Insert a balanced transaction. Throws if SUM(debit) !== SUM(credit) or any
6
+ * posting both debits and credits. Transaction-wrapped: postings never land
7
+ * without a header, header never lands without postings.
8
+ */
9
+ export function recordTransaction(db, input) {
10
+ const validated = validateTransaction(input);
11
+ const tx = db.transaction(() => { insertTransactionRows(db, validated); });
12
+ tx();
13
+ return validated.id;
14
+ }
15
+ /**
16
+ * Validate balance + invariants and assign an id. Pure (no DB writes). Used by
17
+ * both `recordTransaction` and the buffered-scan commit path; the latter
18
+ * already runs inside its own transaction and must not open another.
19
+ */
20
+ export function validateTransaction(input) {
21
+ if (!input.postings || input.postings.length < 2) {
22
+ throw new Error("Transaction must contain at least two postings.");
23
+ }
24
+ let debitTotal = 0;
25
+ let creditTotal = 0;
26
+ for (const p of input.postings) {
27
+ const debit = p.debit ?? 0;
28
+ const credit = p.credit ?? 0;
29
+ if (debit < 0 || credit < 0) {
30
+ throw new Error("debit and credit values must be non-negative.");
31
+ }
32
+ if (debit > 0 && credit > 0) {
33
+ throw new Error("A single posting cannot debit and credit at the same time.");
34
+ }
35
+ if (debit === 0 && credit === 0) {
36
+ throw new Error("Each posting must have either a debit or a credit.");
37
+ }
38
+ debitTotal += debit;
39
+ creditTotal += credit;
40
+ }
41
+ if (Math.abs(debitTotal - creditTotal) > TOLERANCE) {
42
+ throw new Error(`Transaction does not balance: debits ${debitTotal.toFixed(2)} vs credits ${creditTotal.toFixed(2)}.`);
43
+ }
44
+ return { ...input, id: input.id ?? `tx:${randomUUID()}` };
45
+ }
46
+ /**
47
+ * Insert-only counterpart to `recordTransaction`. The caller is responsible
48
+ * for opening a transaction (or for accepting partial writes). Expects an
49
+ * already-validated input from `validateTransaction`.
50
+ */
51
+ export function insertTransactionRows(db, input) {
52
+ let merchantId = input.merchant_id ?? null;
53
+ if (!merchantId && input.merchant) {
54
+ merchantId = upsertMerchant(db, input.merchant).id;
55
+ }
56
+ db.prepare(`INSERT INTO transactions (id, date, description, merchant_id, raw_descriptor, source_file_id, source_page)
57
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.date, input.description, merchantId, input.raw_descriptor ?? null, input.source_file_id ?? null, input.source_page ?? null);
58
+ const insertPosting = db.prepare(`INSERT INTO postings (id, transaction_id, account_id, debit, credit, currency, memo, pii_flag)
59
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
60
+ for (const p of input.postings) {
61
+ insertPosting.run(`p:${randomUUID()}`, input.id, p.account_id, p.debit ?? 0, p.credit ?? 0, p.currency || "THB", p.memo ?? null, p.pii_flag ? 1 : 0);
62
+ }
63
+ }
64
+ export function updateTransaction(db, transactionId, fields) {
65
+ const sets = [];
66
+ const params = [];
67
+ if (fields.date !== undefined) {
68
+ sets.push("date = ?");
69
+ params.push(fields.date);
70
+ }
71
+ if (fields.description !== undefined) {
72
+ sets.push("description = ?");
73
+ params.push(fields.description);
74
+ }
75
+ if (fields.source_page !== undefined) {
76
+ sets.push("source_page = ?");
77
+ params.push(fields.source_page);
78
+ }
79
+ if (sets.length === 0)
80
+ return 0;
81
+ params.push(transactionId);
82
+ return db.prepare(`UPDATE transactions SET ${sets.join(", ")} WHERE id = ?`).run(...params).changes;
83
+ }
84
+ /**
85
+ * Safe single-posting edits only. Refuses changes to `debit`, `credit`, or `currency`
86
+ * because those would silently break the transaction's balance — to fix amounts the
87
+ * caller must delete the transaction and record a fresh one.
88
+ */
89
+ export function updatePosting(db, postingId, fields) {
90
+ const sets = [];
91
+ const params = [];
92
+ if (fields.account_id !== undefined) {
93
+ sets.push("account_id = ?");
94
+ params.push(fields.account_id);
95
+ }
96
+ if (fields.memo !== undefined) {
97
+ sets.push("memo = ?");
98
+ params.push(fields.memo);
99
+ }
100
+ if (sets.length === 0)
101
+ return 0;
102
+ params.push(postingId);
103
+ return db.prepare(`UPDATE postings SET ${sets.join(", ")} WHERE id = ?`).run(...params).changes;
104
+ }
105
+ /**
106
+ * Delete a transaction. ON DELETE CASCADE on `postings.transaction_id` removes
107
+ * the postings automatically.
108
+ */
109
+ export function deleteTransaction(db, transactionId) {
110
+ return db.prepare(`DELETE FROM transactions WHERE id = ?`).run(transactionId).changes;
111
+ }
112
+ /**
113
+ * Heuristic duplicate finder: group transactions by (rounded total debit) and check
114
+ * pairs whose date difference is ≤ toleranceDays. Returns groups with ≥2 members.
115
+ * Each transaction carries both account_ids (for follow-up tool calls) and
116
+ * account_names (for human-readable presentation to the user).
117
+ */
118
+ export function findDuplicateTransactions(db, opts = {}) {
119
+ const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 2));
120
+ const minAmount = opts.minAmount ?? 0;
121
+ const accountFilter = opts.accountId
122
+ ? `WHERE t.id IN (SELECT transaction_id FROM postings WHERE account_id = ?)`
123
+ : ``;
124
+ const params = opts.accountId ? [opts.accountId] : [];
125
+ const nameById = loadAccountNames(db);
126
+ const rows = db.prepare(`SELECT t.id, t.date, t.description,
127
+ COALESCE(SUM(p.debit), 0) AS amount,
128
+ GROUP_CONCAT(p.account_id) AS account_ids
129
+ FROM transactions t
130
+ LEFT JOIN postings p ON p.transaction_id = t.id
131
+ ${accountFilter}
132
+ GROUP BY t.id`).all(...params);
133
+ const candidates = rows
134
+ .filter(r => r.amount >= minAmount)
135
+ .map(r => {
136
+ const ids = (r.account_ids ?? "").split(",").filter(Boolean);
137
+ return {
138
+ id: r.id,
139
+ date: r.date,
140
+ description: r.description,
141
+ amount: Math.round(r.amount * 100) / 100,
142
+ account_ids: ids,
143
+ account_names: ids.map(id => nameById.get(id) ?? id),
144
+ };
145
+ });
146
+ const byAmount = new Map();
147
+ for (const e of candidates) {
148
+ const key = Math.round(e.amount * 100);
149
+ const arr = byAmount.get(key) ?? [];
150
+ arr.push(e);
151
+ byAmount.set(key, arr);
152
+ }
153
+ const groups = [];
154
+ for (const arr of byAmount.values()) {
155
+ if (arr.length < 2)
156
+ continue;
157
+ arr.sort((a, b) => a.date.localeCompare(b.date));
158
+ let current = [];
159
+ for (const e of arr) {
160
+ if (current.length === 0) {
161
+ current.push(e);
162
+ continue;
163
+ }
164
+ const last = current[current.length - 1];
165
+ if (dayDiff(last.date, e.date) <= toleranceDays) {
166
+ current.push(e);
167
+ }
168
+ else {
169
+ if (current.length >= 2)
170
+ groups.push(current);
171
+ current = [e];
172
+ }
173
+ }
174
+ if (current.length >= 2)
175
+ groups.push(current);
176
+ }
177
+ return groups;
178
+ }
179
+ export function dayDiff(a, b) {
180
+ const aDate = Date.parse(a);
181
+ const bDate = Date.parse(b);
182
+ if (Number.isNaN(aDate) || Number.isNaN(bDate))
183
+ return Number.POSITIVE_INFINITY;
184
+ return Math.abs(Math.round((bDate - aDate) / 86_400_000));
185
+ }
186
+ /**
187
+ * Load all account id → name pairs into an in-memory map. Cheap on the small
188
+ * charts of accounts Plasalid deals with, and avoids GROUP_CONCAT join hacks
189
+ * (account names can contain commas which break a comma-separated concat, and
190
+ * SQLite's GROUP_CONCAT has no robust escape mechanism).
191
+ */
192
+ function loadAccountNames(db) {
193
+ const map = new Map();
194
+ for (const row of db.prepare(`SELECT id, name FROM accounts`).all()) {
195
+ map.set(row.id, row.name);
196
+ }
197
+ return map;
198
+ }
199
+ /**
200
+ * Heuristic: surface pairs of transactions that look like the same money movement
201
+ * recorded against different accounts (e.g. a bank-to-card transfer that lands
202
+ * once on the bank statement and again on the card statement). Filters out
203
+ * pairs whose account-id sets overlap (those are duplicates, not correlations).
204
+ */
205
+ export function findCorrelatedTransactions(db, opts = {}) {
206
+ const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
207
+ const minAmount = opts.minAmount ?? 0;
208
+ const dateFilter = [];
209
+ const params = [];
210
+ if (opts.from) {
211
+ dateFilter.push("t.date >= ?");
212
+ params.push(opts.from);
213
+ }
214
+ if (opts.to) {
215
+ dateFilter.push("t.date <= ?");
216
+ params.push(opts.to);
217
+ }
218
+ const where = dateFilter.length ? `WHERE ${dateFilter.join(" AND ")}` : "";
219
+ const nameById = loadAccountNames(db);
220
+ const rows = db.prepare(`SELECT t.id, t.date, t.description,
221
+ COALESCE(SUM(p.debit), 0) AS amount,
222
+ COALESCE(MAX(p.currency), 'THB') AS currency,
223
+ GROUP_CONCAT(p.account_id) AS account_ids
224
+ FROM transactions t
225
+ LEFT JOIN postings p ON p.transaction_id = t.id
226
+ ${where}
227
+ GROUP BY t.id`).all(...params);
228
+ const candidates = rows
229
+ .filter(r => r.amount >= minAmount)
230
+ .map(r => {
231
+ const ids = (r.account_ids ?? "").split(",").filter(Boolean);
232
+ return {
233
+ id: r.id,
234
+ date: r.date,
235
+ description: r.description,
236
+ amount: Math.round(r.amount * 100) / 100,
237
+ currency: r.currency || "THB",
238
+ account_ids: ids,
239
+ account_names: ids.map(id => nameById.get(id) ?? id),
240
+ };
241
+ });
242
+ return correlatePairs(candidates, { toleranceDays });
243
+ }
244
+ /**
245
+ * Pure pair-finder: given an array of candidates already filtered by amount
246
+ * and equipped with account_ids/names, return the cross-pairs that look like
247
+ * the same money movement on different accounts (date within toleranceDays,
248
+ * same amount + currency, non-overlapping account sets).
249
+ *
250
+ * Used by the DB-backed `findCorrelatedTransactions` and by the scan-time
251
+ * coordinator that runs over buffered, not-yet-committed transactions.
252
+ */
253
+ export function correlatePairs(candidates, opts = {}) {
254
+ const toleranceDays = Math.max(0, Math.floor(opts.toleranceDays ?? 3));
255
+ const buckets = new Map();
256
+ for (const e of candidates) {
257
+ const key = `${Math.round(e.amount * 100)}|${e.currency}`;
258
+ const arr = buckets.get(key) ?? [];
259
+ arr.push(e);
260
+ buckets.set(key, arr);
261
+ }
262
+ const pairs = [];
263
+ for (const bucket of buckets.values()) {
264
+ if (bucket.length < 2)
265
+ continue;
266
+ bucket.sort((x, y) => x.date.localeCompare(y.date));
267
+ for (let i = 0; i < bucket.length; i++) {
268
+ for (let j = i + 1; j < bucket.length; j++) {
269
+ const a = bucket[i], b = bucket[j];
270
+ const gap = dayDiff(a.date, b.date);
271
+ if (gap > toleranceDays)
272
+ break;
273
+ const overlap = a.account_ids.some(id => b.account_ids.includes(id));
274
+ if (overlap)
275
+ continue;
276
+ pairs.push({
277
+ amount: a.amount,
278
+ currency: a.currency,
279
+ day_gap: gap,
280
+ a: { id: a.id, date: a.date, description: a.description, account_ids: a.account_ids, account_names: a.account_names },
281
+ b: { id: b.id, date: b.date, description: b.description, account_ids: b.account_ids, account_names: b.account_names },
282
+ });
283
+ }
284
+ }
285
+ }
286
+ return pairs;
287
+ }
288
+ export function listPostings(db, opts = {}) {
289
+ const conditions = [];
290
+ const params = [];
291
+ if (opts.account_id) {
292
+ conditions.push("p.account_id = ?");
293
+ params.push(opts.account_id);
294
+ }
295
+ if (opts.from) {
296
+ conditions.push("t.date >= ?");
297
+ params.push(opts.from);
298
+ }
299
+ if (opts.to) {
300
+ conditions.push("t.date <= ?");
301
+ params.push(opts.to);
302
+ }
303
+ if (opts.q) {
304
+ conditions.push("(t.description LIKE ? OR p.memo LIKE ? OR m.canonical_name LIKE ?)");
305
+ params.push(`%${opts.q}%`, `%${opts.q}%`, `%${opts.q}%`);
306
+ }
307
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
308
+ const limit = Math.min(Math.max(opts.limit ?? 50, 1), 500);
309
+ return db.prepare(`SELECT p.id, p.transaction_id, p.account_id, p.debit, p.credit, p.currency, p.memo,
310
+ a.name AS account_name, a.type AS account_type,
311
+ t.date AS transaction_date, t.description AS transaction_description,
312
+ m.canonical_name AS merchant_name
313
+ FROM postings p
314
+ JOIN transactions t ON t.id = p.transaction_id
315
+ JOIN accounts a ON a.id = p.account_id
316
+ LEFT JOIN merchants m ON m.id = t.merchant_id
317
+ ${where}
318
+ ORDER BY t.date DESC, t.id DESC
319
+ LIMIT ?`).all(...params, limit);
320
+ }