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
package/dist/cli/ux.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import inquirer from "inquirer";
|
|
2
2
|
import ora from "ora";
|
|
3
|
+
import chalk from "chalk";
|
|
3
4
|
import { TOOL_LABELS } from "../ai/tools/index.js";
|
|
4
5
|
import { pickThinking } from "../ai/thinking.js";
|
|
5
6
|
import { formatDuration } from "./format.js";
|
|
@@ -45,9 +46,12 @@ export function statusSpinner(text) {
|
|
|
45
46
|
*/
|
|
46
47
|
export function makePromptUser(spinner) {
|
|
47
48
|
const OTHER = "__plasalid_other__";
|
|
48
|
-
return async (prompt, options) => {
|
|
49
|
+
return async (prompt, options, facts) => {
|
|
49
50
|
spinner.pause();
|
|
50
51
|
console.log("");
|
|
52
|
+
const factsLine = facts ? formatFacts(facts) : null;
|
|
53
|
+
if (factsLine)
|
|
54
|
+
console.log(factsLine);
|
|
51
55
|
try {
|
|
52
56
|
if (options && options.length > 0) {
|
|
53
57
|
const choices = [
|
|
@@ -60,7 +64,18 @@ export function makePromptUser(spinner) {
|
|
|
60
64
|
{ name: "Type a different answer…", value: OTHER },
|
|
61
65
|
];
|
|
62
66
|
const { choice } = await inquirer.prompt([
|
|
63
|
-
{
|
|
67
|
+
{
|
|
68
|
+
type: "list",
|
|
69
|
+
name: "choice",
|
|
70
|
+
message: prompt,
|
|
71
|
+
choices,
|
|
72
|
+
// Stop the cursor at the top/bottom instead of wrapping forever —
|
|
73
|
+
// the wrap-around default makes the list feel infinite.
|
|
74
|
+
loop: false,
|
|
75
|
+
// Show every choice without paginating. Floor of 10 keeps the
|
|
76
|
+
// prompt height predictable for small option sets.
|
|
77
|
+
pageSize: Math.max(choices.length, 10),
|
|
78
|
+
},
|
|
64
79
|
]);
|
|
65
80
|
if (choice === OTHER) {
|
|
66
81
|
const { freeform } = await inquirer.prompt([
|
|
@@ -102,3 +117,22 @@ export function makeAgentOnProgress(spinner, subject) {
|
|
|
102
117
|
}
|
|
103
118
|
};
|
|
104
119
|
}
|
|
120
|
+
/**
|
|
121
|
+
* Render the structured facts the review agent attaches to ask_user as a
|
|
122
|
+
* single colored line above the inquirer prompt. Each category has a fixed
|
|
123
|
+
* chalk color so the user's eye picks out the type without reading prose.
|
|
124
|
+
* Returns null when there's nothing to render (so the caller can skip the
|
|
125
|
+
* blank line entirely).
|
|
126
|
+
*/
|
|
127
|
+
function formatFacts(f) {
|
|
128
|
+
const parts = [];
|
|
129
|
+
if (f.amount)
|
|
130
|
+
parts.push(chalk.yellow(f.amount));
|
|
131
|
+
if (f.date)
|
|
132
|
+
parts.push(chalk.cyan(f.date));
|
|
133
|
+
if (f.merchant)
|
|
134
|
+
parts.push(chalk.green(f.merchant));
|
|
135
|
+
for (const a of f.accounts ?? [])
|
|
136
|
+
parts.push(chalk.magenta(a));
|
|
137
|
+
return parts.length ? parts.join(chalk.dim(" · ")) : null;
|
|
138
|
+
}
|
package/dist/currency.d.ts
CHANGED
|
@@ -3,4 +3,7 @@ export declare function getDisplayCurrency(): string;
|
|
|
3
3
|
export declare function formatCurrencyAmount(amount: number, options?: {
|
|
4
4
|
minimumFractionDigits?: number;
|
|
5
5
|
maximumFractionDigits?: number;
|
|
6
|
+
currency?: string;
|
|
6
7
|
}): string;
|
|
8
|
+
export declare function formatAmount(amount: number, currency?: string): string;
|
|
9
|
+
export declare function formatSignedAmount(amount: number, currency?: string): string;
|
package/dist/currency.js
CHANGED
|
@@ -9,7 +9,7 @@ export function getDisplayCurrency() {
|
|
|
9
9
|
}
|
|
10
10
|
export function formatCurrencyAmount(amount, options = {}) {
|
|
11
11
|
const locale = getDisplayLocale();
|
|
12
|
-
const currency = getDisplayCurrency();
|
|
12
|
+
const currency = options.currency || getDisplayCurrency();
|
|
13
13
|
return new Intl.NumberFormat(locale, {
|
|
14
14
|
style: "currency",
|
|
15
15
|
currency,
|
|
@@ -17,3 +17,14 @@ export function formatCurrencyAmount(amount, options = {}) {
|
|
|
17
17
|
maximumFractionDigits: options.maximumFractionDigits,
|
|
18
18
|
}).format(Math.abs(amount));
|
|
19
19
|
}
|
|
20
|
+
export function formatAmount(amount, currency) {
|
|
21
|
+
return formatCurrencyAmount(amount, {
|
|
22
|
+
minimumFractionDigits: 2,
|
|
23
|
+
maximumFractionDigits: 2,
|
|
24
|
+
currency,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
export function formatSignedAmount(amount, currency) {
|
|
28
|
+
const body = formatAmount(amount, currency);
|
|
29
|
+
return amount < 0 ? `-${body}` : body;
|
|
30
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type Database from "libsql";
|
|
2
2
|
export type AccountType = "asset" | "liability" | "income" | "expense" | "equity";
|
|
3
|
+
export declare const TOP_LEVEL_TYPES: ReadonlyArray<AccountType>;
|
|
3
4
|
export interface AccountRow {
|
|
4
5
|
id: string;
|
|
5
6
|
name: string;
|
|
6
7
|
type: AccountType;
|
|
8
|
+
parent_id: string | null;
|
|
7
9
|
subtype: string | null;
|
|
8
10
|
bank_name: string | null;
|
|
9
11
|
account_number_masked: string | null;
|
|
@@ -13,6 +15,7 @@ export interface AccountRow {
|
|
|
13
15
|
points_balance: number | null;
|
|
14
16
|
metadata_json: string | null;
|
|
15
17
|
pii_flag: number;
|
|
18
|
+
has_concern: number;
|
|
16
19
|
created_at: string;
|
|
17
20
|
}
|
|
18
21
|
export interface AccountBalance extends AccountRow {
|
|
@@ -40,13 +43,79 @@ export declare function getPeriodTotals(db: Database.Database, from: string, to:
|
|
|
40
43
|
export declare function findAccountById(db: Database.Database, id: string): AccountRow | null;
|
|
41
44
|
export declare function renameAccount(db: Database.Database, id: string, name: string): number;
|
|
42
45
|
/**
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
+
* Idempotently insert one of the five top-level type roots (id = type name,
|
|
47
|
+
* parent_id = null). Called by `createAccount` when a child's declared parent
|
|
48
|
+
* is a missing top-level root.
|
|
49
|
+
*/
|
|
50
|
+
export declare function ensureTopLevelRoot(db: Database.Database, type: AccountType): void;
|
|
51
|
+
/**
|
|
52
|
+
* Idempotently insert one of the structural accounts the system auto-creates:
|
|
53
|
+
* - `expense:uncategorized` (suspense for unclassifiable expense postings)
|
|
54
|
+
* - `equity:adjustments` (balancing side of `adjust_account_balance`)
|
|
55
|
+
* - `equity:opening-balance` (starting state imports)
|
|
56
|
+
* The top-level root is bootstrapped first when missing.
|
|
57
|
+
*/
|
|
58
|
+
export declare function ensureStructuralAccount(db: Database.Database, id: "expense:uncategorized" | "equity:adjustments" | "equity:opening-balance"): void;
|
|
59
|
+
export interface CreateAccountInput {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
type: AccountType;
|
|
63
|
+
parent_id?: string | null;
|
|
64
|
+
subtype?: string | null;
|
|
65
|
+
bank_name?: string | null;
|
|
66
|
+
account_number_masked?: string | null;
|
|
67
|
+
currency?: string;
|
|
68
|
+
due_day?: number | null;
|
|
69
|
+
statement_day?: number | null;
|
|
70
|
+
metadata?: Record<string, unknown> | null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Insert a new account row. Enforces the three hierarchy invariants:
|
|
74
|
+
* 1. Top-level roots: parent_id null, id == type, one of TOP_LEVEL_TYPES.
|
|
75
|
+
* 2. Children: parent_id non-null, parent must exist (the top-level root is
|
|
76
|
+
* auto-bootstrapped if missing — intermediate categories must be created
|
|
77
|
+
* explicitly), parent.type must equal input.type, input.id must start with
|
|
78
|
+
* parent.id + ':'.
|
|
79
|
+
* 3. UNIQUE on id (surfaces as code: 'ACCOUNT_EXISTS').
|
|
80
|
+
*/
|
|
81
|
+
export declare function createAccount(db: Database.Database, input: CreateAccountInput): void;
|
|
82
|
+
export interface UpdateAccountMetadataPatch {
|
|
83
|
+
due_day?: number | null;
|
|
84
|
+
statement_day?: number | null;
|
|
85
|
+
points_balance?: number | null;
|
|
86
|
+
account_number_masked?: string | null;
|
|
87
|
+
bank_name?: string | null;
|
|
88
|
+
metadata?: Record<string, unknown>;
|
|
89
|
+
}
|
|
90
|
+
export interface UpdateAccountMetadataResult {
|
|
91
|
+
before: Record<string, unknown>;
|
|
92
|
+
after: Record<string, unknown>;
|
|
93
|
+
changed: boolean;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Patch metadata fields on an account. Returns before/after snapshots of the
|
|
97
|
+
* touched fields so callers can persist a reversible audit record. `metadata`
|
|
98
|
+
* is shallow-merged into the existing metadata_json blob.
|
|
99
|
+
*/
|
|
100
|
+
export declare function updateAccountMetadata(db: Database.Database, id: string, patch: UpdateAccountMetadataPatch): UpdateAccountMetadataResult;
|
|
101
|
+
/**
|
|
102
|
+
* Re-point every posting on `fromId` to `toId`, then delete the source account.
|
|
103
|
+
* Wrapped in a transaction. Refuses if the source still has children. Returns
|
|
104
|
+
* the number of postings moved.
|
|
46
105
|
*/
|
|
47
106
|
export declare function mergeAccounts(db: Database.Database, fromId: string, toId: string): number;
|
|
48
|
-
/** Delete an account only if no
|
|
107
|
+
/** Delete an account only if no postings reference it AND it has no children. */
|
|
49
108
|
export declare function deleteAccount(db: Database.Database, id: string): void;
|
|
109
|
+
/**
|
|
110
|
+
* Recursive CTE walk over `accounts.parent_id` returning the root and every
|
|
111
|
+
* descendant. Used by `getRollupBalance` and by hierarchical rendering paths.
|
|
112
|
+
*/
|
|
113
|
+
export declare function getAccountSubtree(db: Database.Database, rootId: string): AccountRow[];
|
|
114
|
+
/**
|
|
115
|
+
* Sum the natural balance of every account in a subtree (root inclusive).
|
|
116
|
+
* Uses the same debit-normal / credit-normal convention as `getAccountBalances`.
|
|
117
|
+
*/
|
|
118
|
+
export declare function getRollupBalance(db: Database.Database, rootId: string): number;
|
|
50
119
|
export interface SimilarAccountPair {
|
|
51
120
|
a: AccountRow;
|
|
52
121
|
b: AccountRow;
|
|
@@ -59,3 +128,14 @@ export interface SimilarAccountPair {
|
|
|
59
128
|
*/
|
|
60
129
|
export declare function findSimilarAccounts(db: Database.Database, threshold?: number): SimilarAccountPair[];
|
|
61
130
|
export declare function findUnusedAccounts(db: Database.Database): AccountRow[];
|
|
131
|
+
export interface FuzzyAccountMatch {
|
|
132
|
+
account: AccountRow;
|
|
133
|
+
similarity: number;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Rank the chart of accounts by name similarity to a free-text query. Returns
|
|
137
|
+
* matches at or above `threshold`, highest first. Bonus weight when the query
|
|
138
|
+
* is a substring of the name so "ttb saving" still finds "TTB Savings ••1234"
|
|
139
|
+
* even though pure Levenshtein on the full strings is mediocre.
|
|
140
|
+
*/
|
|
141
|
+
export declare function findAccountsByFuzzyName(db: Database.Database, query: string, threshold?: number): FuzzyAccountMatch[];
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
export const TOP_LEVEL_TYPES = [
|
|
2
|
+
"asset", "liability", "income", "expense", "equity",
|
|
3
|
+
];
|
|
4
|
+
const TYPE_ROOT_NAME = {
|
|
5
|
+
asset: "Assets",
|
|
6
|
+
liability: "Liabilities",
|
|
7
|
+
income: "Income",
|
|
8
|
+
expense: "Expenses",
|
|
9
|
+
equity: "Equity",
|
|
10
|
+
};
|
|
1
11
|
/**
|
|
2
12
|
* Balance per account using the natural debit/credit convention:
|
|
3
13
|
* asset / expense → debit-normal → balance = debits − credits
|
|
@@ -12,13 +22,13 @@ export function getAccountBalances(db, opts = {}) {
|
|
|
12
22
|
}
|
|
13
23
|
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
14
24
|
const rows = db.prepare(`SELECT a.*,
|
|
15
|
-
COALESCE(SUM(
|
|
16
|
-
COALESCE(SUM(
|
|
25
|
+
COALESCE(SUM(p.debit), 0) AS sum_debit,
|
|
26
|
+
COALESCE(SUM(p.credit), 0) AS sum_credit
|
|
17
27
|
FROM accounts a
|
|
18
|
-
LEFT JOIN
|
|
28
|
+
LEFT JOIN postings p ON p.account_id = a.id
|
|
19
29
|
${whereSql}
|
|
20
30
|
GROUP BY a.id
|
|
21
|
-
ORDER BY a.type, a.
|
|
31
|
+
ORDER BY a.type, a.id`).all(...params);
|
|
22
32
|
return rows.map(r => {
|
|
23
33
|
const debitNormal = r.type === "asset" || r.type === "expense";
|
|
24
34
|
const balance = debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
|
|
@@ -40,12 +50,12 @@ export function getNetWorth(db) {
|
|
|
40
50
|
}
|
|
41
51
|
export function getPeriodTotals(db, from, to) {
|
|
42
52
|
const row = db.prepare(`SELECT
|
|
43
|
-
COALESCE(SUM(CASE WHEN a.type = 'income' THEN
|
|
44
|
-
COALESCE(SUM(CASE WHEN a.type = 'expense' THEN
|
|
45
|
-
FROM
|
|
46
|
-
JOIN
|
|
47
|
-
JOIN accounts a ON a.id =
|
|
48
|
-
WHERE
|
|
53
|
+
COALESCE(SUM(CASE WHEN a.type = 'income' THEN p.credit - p.debit ELSE 0 END), 0) AS income,
|
|
54
|
+
COALESCE(SUM(CASE WHEN a.type = 'expense' THEN p.debit - p.credit ELSE 0 END), 0) AS expenses
|
|
55
|
+
FROM postings p
|
|
56
|
+
JOIN transactions t ON t.id = p.transaction_id
|
|
57
|
+
JOIN accounts a ON a.id = p.account_id
|
|
58
|
+
WHERE t.date BETWEEN ? AND ?`).get(from, to);
|
|
49
59
|
return { income: row.income, expenses: row.expenses };
|
|
50
60
|
}
|
|
51
61
|
export function findAccountById(db, id) {
|
|
@@ -55,9 +65,145 @@ export function renameAccount(db, id, name) {
|
|
|
55
65
|
return db.prepare(`UPDATE accounts SET name = ? WHERE id = ?`).run(name, id).changes;
|
|
56
66
|
}
|
|
57
67
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
68
|
+
* Idempotently insert one of the five top-level type roots (id = type name,
|
|
69
|
+
* parent_id = null). Called by `createAccount` when a child's declared parent
|
|
70
|
+
* is a missing top-level root.
|
|
71
|
+
*/
|
|
72
|
+
export function ensureTopLevelRoot(db, type) {
|
|
73
|
+
if (findAccountById(db, type))
|
|
74
|
+
return;
|
|
75
|
+
db.prepare(`INSERT INTO accounts (id, name, type, parent_id) VALUES (?, ?, ?, NULL)`).run(type, TYPE_ROOT_NAME[type], type);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Idempotently insert one of the structural accounts the system auto-creates:
|
|
79
|
+
* - `expense:uncategorized` (suspense for unclassifiable expense postings)
|
|
80
|
+
* - `equity:adjustments` (balancing side of `adjust_account_balance`)
|
|
81
|
+
* - `equity:opening-balance` (starting state imports)
|
|
82
|
+
* The top-level root is bootstrapped first when missing.
|
|
83
|
+
*/
|
|
84
|
+
export function ensureStructuralAccount(db, id) {
|
|
85
|
+
if (findAccountById(db, id))
|
|
86
|
+
return;
|
|
87
|
+
const [type, leaf] = id.split(":");
|
|
88
|
+
ensureTopLevelRoot(db, type);
|
|
89
|
+
const name = leaf === "uncategorized" ? "Uncategorized"
|
|
90
|
+
: leaf === "adjustments" ? "Adjustments"
|
|
91
|
+
: "Opening Balance";
|
|
92
|
+
db.prepare(`INSERT INTO accounts (id, name, type, parent_id) VALUES (?, ?, ?, ?)`).run(id, name, type, type);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Insert a new account row. Enforces the three hierarchy invariants:
|
|
96
|
+
* 1. Top-level roots: parent_id null, id == type, one of TOP_LEVEL_TYPES.
|
|
97
|
+
* 2. Children: parent_id non-null, parent must exist (the top-level root is
|
|
98
|
+
* auto-bootstrapped if missing — intermediate categories must be created
|
|
99
|
+
* explicitly), parent.type must equal input.type, input.id must start with
|
|
100
|
+
* parent.id + ':'.
|
|
101
|
+
* 3. UNIQUE on id (surfaces as code: 'ACCOUNT_EXISTS').
|
|
102
|
+
*/
|
|
103
|
+
export function createAccount(db, input) {
|
|
104
|
+
const bank = input.bank_name ? String(input.bank_name).toUpperCase() : null;
|
|
105
|
+
const parentId = input.parent_id ?? null;
|
|
106
|
+
if (parentId === null) {
|
|
107
|
+
if (!TOP_LEVEL_TYPES.includes(input.id)) {
|
|
108
|
+
throw new Error(`Account "${input.id}" has no parent_id; only top-level type roots may have a null parent (one of ${TOP_LEVEL_TYPES.join(", ")}).`);
|
|
109
|
+
}
|
|
110
|
+
if (input.id !== input.type) {
|
|
111
|
+
throw new Error(`Top-level root id "${input.id}" must equal its type "${input.type}".`);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else {
|
|
115
|
+
let parent = findAccountById(db, parentId);
|
|
116
|
+
if (!parent) {
|
|
117
|
+
if (TOP_LEVEL_TYPES.includes(parentId)) {
|
|
118
|
+
ensureTopLevelRoot(db, parentId);
|
|
119
|
+
parent = findAccountById(db, parentId);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (!parent) {
|
|
123
|
+
throw new Error(`Parent account "${parentId}" does not exist; create it first.`);
|
|
124
|
+
}
|
|
125
|
+
if (parent.type !== input.type) {
|
|
126
|
+
throw new Error(`Account "${input.id}" type "${input.type}" does not match parent "${parentId}" type "${parent.type}".`);
|
|
127
|
+
}
|
|
128
|
+
if (!input.id.startsWith(parent.id + ":")) {
|
|
129
|
+
throw new Error(`Account id "${input.id}" must start with parent id "${parent.id}:".`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
db.prepare(`INSERT INTO accounts (id, name, type, parent_id, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
|
|
134
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.name, input.type, parentId, input.subtype ?? null, bank, input.account_number_masked ?? null, input.currency ?? "THB", input.due_day ?? null, input.statement_day ?? null, input.metadata ? JSON.stringify(input.metadata) : null);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (String(err.message).includes("UNIQUE")) {
|
|
138
|
+
const dup = new Error(`Account "${input.id}" already exists.`);
|
|
139
|
+
dup.code = "ACCOUNT_EXISTS";
|
|
140
|
+
throw dup;
|
|
141
|
+
}
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Patch metadata fields on an account. Returns before/after snapshots of the
|
|
147
|
+
* touched fields so callers can persist a reversible audit record. `metadata`
|
|
148
|
+
* is shallow-merged into the existing metadata_json blob.
|
|
149
|
+
*/
|
|
150
|
+
export function updateAccountMetadata(db, id, patch) {
|
|
151
|
+
const current = findAccountById(db, id);
|
|
152
|
+
if (!current)
|
|
153
|
+
throw new Error(`Account "${id}" not found.`);
|
|
154
|
+
const sets = [];
|
|
155
|
+
const params = [];
|
|
156
|
+
const before = {};
|
|
157
|
+
const after = {};
|
|
158
|
+
if (patch.due_day !== undefined) {
|
|
159
|
+
sets.push("due_day = ?");
|
|
160
|
+
params.push(patch.due_day);
|
|
161
|
+
before.due_day = current.due_day;
|
|
162
|
+
after.due_day = patch.due_day;
|
|
163
|
+
}
|
|
164
|
+
if (patch.statement_day !== undefined) {
|
|
165
|
+
sets.push("statement_day = ?");
|
|
166
|
+
params.push(patch.statement_day);
|
|
167
|
+
before.statement_day = current.statement_day;
|
|
168
|
+
after.statement_day = patch.statement_day;
|
|
169
|
+
}
|
|
170
|
+
if (patch.points_balance !== undefined) {
|
|
171
|
+
sets.push("points_balance = ?");
|
|
172
|
+
params.push(patch.points_balance);
|
|
173
|
+
before.points_balance = current.points_balance;
|
|
174
|
+
after.points_balance = patch.points_balance;
|
|
175
|
+
}
|
|
176
|
+
if (patch.account_number_masked !== undefined) {
|
|
177
|
+
sets.push("account_number_masked = ?");
|
|
178
|
+
params.push(patch.account_number_masked);
|
|
179
|
+
before.account_number_masked = current.account_number_masked;
|
|
180
|
+
after.account_number_masked = patch.account_number_masked;
|
|
181
|
+
}
|
|
182
|
+
if (patch.bank_name !== undefined) {
|
|
183
|
+
const next = patch.bank_name == null ? null : String(patch.bank_name).toUpperCase();
|
|
184
|
+
sets.push("bank_name = ?");
|
|
185
|
+
params.push(next);
|
|
186
|
+
before.bank_name = current.bank_name;
|
|
187
|
+
after.bank_name = next;
|
|
188
|
+
}
|
|
189
|
+
if (patch.metadata) {
|
|
190
|
+
const existing = current.metadata_json ? JSON.parse(current.metadata_json) : {};
|
|
191
|
+
const merged = { ...existing, ...patch.metadata };
|
|
192
|
+
sets.push("metadata_json = ?");
|
|
193
|
+
params.push(JSON.stringify(merged));
|
|
194
|
+
before.metadata = existing;
|
|
195
|
+
after.metadata = merged;
|
|
196
|
+
}
|
|
197
|
+
if (sets.length === 0)
|
|
198
|
+
return { before, after, changed: false };
|
|
199
|
+
params.push(id);
|
|
200
|
+
db.prepare(`UPDATE accounts SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
201
|
+
return { before, after, changed: true };
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Re-point every posting on `fromId` to `toId`, then delete the source account.
|
|
205
|
+
* Wrapped in a transaction. Refuses if the source still has children. Returns
|
|
206
|
+
* the number of postings moved.
|
|
61
207
|
*/
|
|
62
208
|
export function mergeAccounts(db, fromId, toId) {
|
|
63
209
|
if (fromId === toId)
|
|
@@ -68,26 +214,74 @@ export function mergeAccounts(db, fromId, toId) {
|
|
|
68
214
|
const to = findAccountById(db, toId);
|
|
69
215
|
if (!to)
|
|
70
216
|
throw new Error(`Destination account ${toId} not found.`);
|
|
217
|
+
const childCount = db
|
|
218
|
+
.prepare(`SELECT COUNT(*) AS n FROM accounts WHERE parent_id = ?`)
|
|
219
|
+
.get(fromId);
|
|
220
|
+
if (childCount.n > 0) {
|
|
221
|
+
throw new Error(`Account ${fromId} has ${childCount.n} child account(s); merge or delete them first.`);
|
|
222
|
+
}
|
|
71
223
|
let moved = 0;
|
|
72
224
|
const tx = db.transaction(() => {
|
|
73
225
|
moved = db
|
|
74
|
-
.prepare(`UPDATE
|
|
226
|
+
.prepare(`UPDATE postings SET account_id = ? WHERE account_id = ?`)
|
|
75
227
|
.run(toId, fromId).changes;
|
|
76
228
|
db.prepare(`DELETE FROM accounts WHERE id = ?`).run(fromId);
|
|
77
229
|
});
|
|
78
230
|
tx();
|
|
79
231
|
return moved;
|
|
80
232
|
}
|
|
81
|
-
/** Delete an account only if no
|
|
233
|
+
/** Delete an account only if no postings reference it AND it has no children. */
|
|
82
234
|
export function deleteAccount(db, id) {
|
|
83
235
|
const inUse = db
|
|
84
|
-
.prepare(`SELECT 1 FROM
|
|
236
|
+
.prepare(`SELECT 1 FROM postings WHERE account_id = ? LIMIT 1`)
|
|
85
237
|
.get(id);
|
|
86
238
|
if (inUse) {
|
|
87
|
-
throw new Error(`Account ${id} still has
|
|
239
|
+
throw new Error(`Account ${id} still has postings; merge it first.`);
|
|
240
|
+
}
|
|
241
|
+
const childCount = db
|
|
242
|
+
.prepare(`SELECT COUNT(*) AS n FROM accounts WHERE parent_id = ?`)
|
|
243
|
+
.get(id);
|
|
244
|
+
if (childCount.n > 0) {
|
|
245
|
+
throw new Error(`Account ${id} has ${childCount.n} child account(s); delete them first.`);
|
|
88
246
|
}
|
|
89
247
|
db.prepare(`DELETE FROM accounts WHERE id = ?`).run(id);
|
|
90
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Recursive CTE walk over `accounts.parent_id` returning the root and every
|
|
251
|
+
* descendant. Used by `getRollupBalance` and by hierarchical rendering paths.
|
|
252
|
+
*/
|
|
253
|
+
export function getAccountSubtree(db, rootId) {
|
|
254
|
+
return db.prepare(`WITH RECURSIVE subtree AS (
|
|
255
|
+
SELECT * FROM accounts WHERE id = ?
|
|
256
|
+
UNION ALL
|
|
257
|
+
SELECT a.* FROM accounts a JOIN subtree s ON a.parent_id = s.id
|
|
258
|
+
)
|
|
259
|
+
SELECT * FROM subtree ORDER BY id`).all(rootId);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Sum the natural balance of every account in a subtree (root inclusive).
|
|
263
|
+
* Uses the same debit-normal / credit-normal convention as `getAccountBalances`.
|
|
264
|
+
*/
|
|
265
|
+
export function getRollupBalance(db, rootId) {
|
|
266
|
+
const subtree = getAccountSubtree(db, rootId);
|
|
267
|
+
if (subtree.length === 0)
|
|
268
|
+
return 0;
|
|
269
|
+
const ids = subtree.map(a => a.id);
|
|
270
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
271
|
+
const row = db.prepare(`SELECT a.type,
|
|
272
|
+
COALESCE(SUM(p.debit), 0) AS sum_debit,
|
|
273
|
+
COALESCE(SUM(p.credit), 0) AS sum_credit
|
|
274
|
+
FROM accounts a
|
|
275
|
+
LEFT JOIN postings p ON p.account_id = a.id
|
|
276
|
+
WHERE a.id IN (${placeholders})
|
|
277
|
+
GROUP BY a.type`).all(...ids);
|
|
278
|
+
let total = 0;
|
|
279
|
+
for (const r of row) {
|
|
280
|
+
const debitNormal = r.type === "asset" || r.type === "expense";
|
|
281
|
+
total += debitNormal ? r.sum_debit - r.sum_credit : r.sum_credit - r.sum_debit;
|
|
282
|
+
}
|
|
283
|
+
return total;
|
|
284
|
+
}
|
|
91
285
|
/**
|
|
92
286
|
* Pairwise Levenshtein similarity over `accounts.name`. Returns pairs above the
|
|
93
287
|
* threshold (0–1, where 1 = identical), sorted highest first. Quadratic in the
|
|
@@ -109,12 +303,37 @@ export function findSimilarAccounts(db, threshold = 0.85) {
|
|
|
109
303
|
export function findUnusedAccounts(db) {
|
|
110
304
|
return db
|
|
111
305
|
.prepare(`SELECT a.* FROM accounts a
|
|
112
|
-
LEFT JOIN
|
|
113
|
-
WHERE
|
|
306
|
+
LEFT JOIN postings p ON p.account_id = a.id
|
|
307
|
+
WHERE p.id IS NULL
|
|
308
|
+
AND NOT EXISTS (SELECT 1 FROM accounts c WHERE c.parent_id = a.id)
|
|
309
|
+
AND a.id NOT IN ('asset','liability','income','expense','equity')
|
|
114
310
|
ORDER BY a.name`)
|
|
115
311
|
.all();
|
|
116
312
|
}
|
|
117
|
-
|
|
313
|
+
/**
|
|
314
|
+
* Rank the chart of accounts by name similarity to a free-text query. Returns
|
|
315
|
+
* matches at or above `threshold`, highest first. Bonus weight when the query
|
|
316
|
+
* is a substring of the name so "ttb saving" still finds "TTB Savings ••1234"
|
|
317
|
+
* even though pure Levenshtein on the full strings is mediocre.
|
|
318
|
+
*/
|
|
319
|
+
export function findAccountsByFuzzyName(db, query, threshold = 0.5) {
|
|
320
|
+
const q = query.trim().toLowerCase();
|
|
321
|
+
if (!q)
|
|
322
|
+
return [];
|
|
323
|
+
const rows = db.prepare(`SELECT * FROM accounts ORDER BY name`).all();
|
|
324
|
+
const out = [];
|
|
325
|
+
for (const row of rows) {
|
|
326
|
+
const name = row.name.toLowerCase();
|
|
327
|
+
let score = similarity(q, name);
|
|
328
|
+
if (name.includes(q) || q.includes(name))
|
|
329
|
+
score = Math.max(score, 0.85);
|
|
330
|
+
if (score >= threshold) {
|
|
331
|
+
out.push({ account: row, similarity: Math.round(score * 1000) / 1000 });
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
out.sort((a, b) => b.similarity - a.similarity);
|
|
335
|
+
return out;
|
|
336
|
+
}
|
|
118
337
|
function similarity(a, b) {
|
|
119
338
|
if (a === b)
|
|
120
339
|
return 1;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
export type ActionCommand = "record" | "scan" | "review";
|
|
3
|
+
export type ActionType = "create_account" | "update_account_metadata" | "record_transaction" | "adjust_balance" | "create_merchant" | "update_merchant_default";
|
|
4
|
+
export interface ActionLogInput {
|
|
5
|
+
correlation_id: string;
|
|
6
|
+
command: ActionCommand;
|
|
7
|
+
user_input?: string | null;
|
|
8
|
+
action_type: ActionType;
|
|
9
|
+
target_id: string;
|
|
10
|
+
payload: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
export interface ActionLogRow {
|
|
13
|
+
id: string;
|
|
14
|
+
correlation_id: string;
|
|
15
|
+
command: ActionCommand;
|
|
16
|
+
user_input: string | null;
|
|
17
|
+
action_type: ActionType;
|
|
18
|
+
target_id: string;
|
|
19
|
+
payload_json: string;
|
|
20
|
+
created_at: string;
|
|
21
|
+
reverted_at: string | null;
|
|
22
|
+
}
|
|
23
|
+
export declare function appendAction(db: Database.Database, input: ActionLogInput): string;
|
|
24
|
+
export interface ListActionsOptions {
|
|
25
|
+
limit?: number;
|
|
26
|
+
command?: ActionCommand;
|
|
27
|
+
correlationId?: string;
|
|
28
|
+
}
|
|
29
|
+
export declare function listActions(db: Database.Database, opts?: ListActionsOptions): ActionLogRow[];
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
export function appendAction(db, input) {
|
|
3
|
+
const id = `al:${randomUUID()}`;
|
|
4
|
+
db.prepare(`INSERT INTO action_log
|
|
5
|
+
(id, correlation_id, command, user_input, action_type, target_id, payload_json)
|
|
6
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, input.correlation_id, input.command, input.user_input ?? null, input.action_type, input.target_id, JSON.stringify(input.payload));
|
|
7
|
+
return id;
|
|
8
|
+
}
|
|
9
|
+
export function listActions(db, opts = {}) {
|
|
10
|
+
const conds = [];
|
|
11
|
+
const params = [];
|
|
12
|
+
if (opts.command) {
|
|
13
|
+
conds.push("command = ?");
|
|
14
|
+
params.push(opts.command);
|
|
15
|
+
}
|
|
16
|
+
if (opts.correlationId) {
|
|
17
|
+
conds.push("correlation_id = ?");
|
|
18
|
+
params.push(opts.correlationId);
|
|
19
|
+
}
|
|
20
|
+
const where = conds.length ? `WHERE ${conds.join(" AND ")}` : "";
|
|
21
|
+
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 1000);
|
|
22
|
+
return db
|
|
23
|
+
.prepare(`SELECT id, correlation_id, command, user_input, action_type, target_id,
|
|
24
|
+
payload_json, created_at, reverted_at
|
|
25
|
+
FROM action_log ${where} ORDER BY rowid ASC LIMIT ?`)
|
|
26
|
+
.all(...params, limit);
|
|
27
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type Database from "libsql";
|
|
2
|
+
export interface ConcernTarget {
|
|
3
|
+
transaction_id: string | null;
|
|
4
|
+
account_id: string | null;
|
|
5
|
+
}
|
|
6
|
+
export interface RecordConcernInput extends ConcernTarget {
|
|
7
|
+
file_id: string | null;
|
|
8
|
+
kind?: string | null;
|
|
9
|
+
prompt: string;
|
|
10
|
+
options?: string[];
|
|
11
|
+
}
|
|
12
|
+
export interface OpenConcernRow {
|
|
13
|
+
id: string;
|
|
14
|
+
file_id: string | null;
|
|
15
|
+
transaction_id: string | null;
|
|
16
|
+
account_id: string | null;
|
|
17
|
+
kind: string | null;
|
|
18
|
+
prompt: string;
|
|
19
|
+
options_json: string | null;
|
|
20
|
+
created_at: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Insert a new concerns row and flip the `has_concern` boolean on whichever
|
|
24
|
+
* target (transaction / account) was named. Returns the new `cn:<uuid>` id.
|
|
25
|
+
*/
|
|
26
|
+
export declare function recordConcern(db: Database.Database, input: RecordConcernInput): string;
|
|
27
|
+
/**
|
|
28
|
+
* Mark an existing concern as resolved with the user's answer and, if no other
|
|
29
|
+
* open concerns reference the same target, clear the target's `has_concern`
|
|
30
|
+
* flag. Returns the concern's target so callers can log or react.
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveConcern(db: Database.Database, id: string, answer: string): ConcernTarget | null;
|
|
33
|
+
/**
|
|
34
|
+
* Look up the transaction/account a concern is attached to. Returns null when
|
|
35
|
+
* the concern id doesn't exist.
|
|
36
|
+
*/
|
|
37
|
+
export declare function getConcernTarget(db: Database.Database, id: string): ConcernTarget | null;
|
|
38
|
+
/**
|
|
39
|
+
* Clear `has_concern` on the named transaction / account if no other open concerns
|
|
40
|
+
* still reference it. Safe to call after any concern resolution; idempotent.
|
|
41
|
+
*/
|
|
42
|
+
export declare function maybeClearHasConcernFlags(db: Database.Database, target: ConcernTarget): void;
|
|
43
|
+
export interface CountOpenConcernsScope {
|
|
44
|
+
file_id?: string;
|
|
45
|
+
transaction_id?: string;
|
|
46
|
+
account_id?: string;
|
|
47
|
+
kind?: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function countOpenConcerns(db: Database.Database, scope?: CountOpenConcernsScope): number;
|
|
50
|
+
export declare function listOpenConcerns(db: Database.Database, limit?: number): OpenConcernRow[];
|