plasalid 0.4.1 → 0.5.1
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 +15 -14
- package/dist/ai/agent.d.ts +15 -2
- package/dist/ai/agent.js +21 -2
- package/dist/ai/memory.d.ts +2 -0
- package/dist/ai/memory.js +2 -2
- package/dist/ai/personas.d.ts +2 -1
- package/dist/ai/personas.js +115 -45
- package/dist/ai/prompt-sections.d.ts +5 -0
- package/dist/ai/prompt-sections.js +26 -8
- package/dist/ai/system-prompt.d.ts +11 -0
- package/dist/ai/system-prompt.js +21 -6
- package/dist/ai/thinking.js +1 -1
- package/dist/ai/tools/common.js +2 -5
- package/dist/ai/tools/index.js +28 -8
- package/dist/ai/tools/ingest.d.ts +2 -1
- package/dist/ai/tools/ingest.js +262 -151
- package/dist/ai/tools/merchants.d.ts +2 -0
- package/dist/ai/tools/merchants.js +117 -0
- package/dist/ai/tools/read.js +31 -29
- package/dist/ai/tools/record.d.ts +2 -0
- package/dist/ai/tools/record.js +188 -0
- package/dist/ai/tools/review.js +77 -80
- package/dist/ai/tools/scan.js +1 -1
- package/dist/ai/tools/types.d.ts +15 -6
- 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/scan.js +15 -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 +19 -7
- package/dist/cli/ink/hooks/useFooterText.js +2 -1
- package/dist/cli/ink/scan_dashboard.d.ts +1 -1
- package/dist/cli/ink/scan_dashboard.js +2 -2
- package/dist/cli/setup.d.ts +0 -1
- package/dist/cli/setup.js +2 -8
- package/dist/currency.d.ts +3 -0
- package/dist/currency.js +12 -1
- package/dist/db/queries/account_balance.d.ts +83 -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 +10 -7
- package/dist/db/queries/concerns.js +20 -16
- package/dist/db/queries/journal.d.ts +1 -0
- package/dist/db/queries/merchants.d.ts +42 -0
- package/dist/db/queries/merchants.js +120 -0
- package/dist/db/queries/recurrences.d.ts +3 -3
- package/dist/db/queries/recurrences.js +32 -34
- 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 +51 -9
- package/dist/reviewer/pipeline.d.ts +4 -4
- package/dist/reviewer/pipeline.js +4 -4
- package/dist/reviewer/prompts.js +4 -4
- package/dist/scanner/buffer.d.ts +24 -21
- package/dist/scanner/buffer.js +18 -18
- package/dist/scanner/pipeline.d.ts +3 -2
- package/dist/scanner/pipeline.js +33 -36
- package/dist/scanner/prompts.js +3 -3
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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 "./
|
|
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
|
|
5
|
+
const accountFilter = opts.accountId ? `AND p.account_id = ?` : ``;
|
|
6
6
|
const params = opts.accountId ? [opts.accountId] : [];
|
|
7
|
-
const rows = db.prepare(`SELECT
|
|
7
|
+
const rows = db.prepare(`SELECT p.account_id,
|
|
8
8
|
a.name AS account_name,
|
|
9
|
-
|
|
10
|
-
CASE WHEN
|
|
11
|
-
CASE WHEN
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
FROM
|
|
16
|
-
JOIN
|
|
17
|
-
JOIN accounts a ON a.id =
|
|
18
|
-
WHERE
|
|
19
|
-
AND (
|
|
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
|
|
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
|
-
|
|
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.
|
|
78
|
-
throw new Error("recordRecurrence requires at least one
|
|
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.
|
|
81
|
-
const dateRows = db.prepare(`SELECT date FROM
|
|
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
|
|
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
|
|
95
|
-
for (const
|
|
96
|
-
|
|
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
|
|
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
|
|
108
|
-
.prepare(`SELECT date FROM
|
|
109
|
-
.get(
|
|
110
|
-
if (!
|
|
111
|
-
throw new Error(`
|
|
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
|
|
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
|
|
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 {
|
|
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[];
|