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,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 {
|
|
2
|
+
import type { PostingRow } from "./transactions.js";
|
|
3
3
|
/**
|
|
4
|
-
* Free-text search across
|
|
5
|
-
* names. Returns matching
|
|
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
|
|
8
|
+
export declare function searchPostings(db: Database.Database, query: string, limit?: number): PostingRow[];
|
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Free-text search across
|
|
3
|
-
* names. Returns matching
|
|
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
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
JOIN
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
+
}
|