@morroc/open-ledger 0.14.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/LICENSE +202 -0
- package/README.md +191 -0
- package/datasets/defaults/cn.json +1 -0
- package/datasets/defaults/jp.json +1 -0
- package/datasets/defaults/th.json +1 -0
- package/datasets/defaults/us.json +1 -0
- package/datasets/institutions/cn.json +47 -0
- package/datasets/institutions/jp.json +59 -0
- package/datasets/institutions/th.json +99 -0
- package/datasets/institutions/us.json +70 -0
- package/dist/accounts/accounts.js +99 -0
- package/dist/accounts/balances.js +109 -0
- package/dist/accounts/matching.js +72 -0
- package/dist/accounts/resolve.js +118 -0
- package/dist/cli/commands/accounts.js +477 -0
- package/dist/cli/commands/config.js +175 -0
- package/dist/cli/commands/datasets.js +56 -0
- package/dist/cli/commands/doctor.js +153 -0
- package/dist/cli/commands/files.js +72 -0
- package/dist/cli/commands/ingest-commit.js +279 -0
- package/dist/cli/commands/ingest.js +203 -0
- package/dist/cli/commands/merchants.js +140 -0
- package/dist/cli/commands/notes.js +71 -0
- package/dist/cli/commands/open.js +54 -0
- package/dist/cli/commands/questions.js +119 -0
- package/dist/cli/commands/report.js +50 -0
- package/dist/cli/commands/setup.js +61 -0
- package/dist/cli/commands/status.js +187 -0
- package/dist/cli/commands/transactions.js +420 -0
- package/dist/cli/commands/vault.js +65 -0
- package/dist/cli/currency.js +28 -0
- package/dist/cli/db.js +5 -0
- package/dist/cli/format.js +71 -0
- package/dist/cli/index.js +19 -0
- package/dist/cli/output.js +304 -0
- package/dist/cli/program.js +140 -0
- package/dist/config.js +104 -0
- package/dist/context.js +30 -0
- package/dist/datasets/defaults.js +29 -0
- package/dist/datasets/index.js +33 -0
- package/dist/datasets/institutions.js +31 -0
- package/dist/datasets/loader.js +57 -0
- package/dist/db/connection.js +39 -0
- package/dist/db/encryption.js +42 -0
- package/dist/db/migrations/0001_baseline.js +121 -0
- package/dist/db/migrations/index.js +3 -0
- package/dist/db/queries/accounts.js +108 -0
- package/dist/db/queries/balances.js +61 -0
- package/dist/db/queries/files.js +72 -0
- package/dist/db/queries/merchants.js +163 -0
- package/dist/db/queries/notes.js +28 -0
- package/dist/db/queries/questions.js +117 -0
- package/dist/db/queries/transactions-dedup.js +80 -0
- package/dist/db/queries/transactions.js +319 -0
- package/dist/db/queries/vault.js +48 -0
- package/dist/db/schema.js +105 -0
- package/dist/extract/extract.js +89 -0
- package/dist/extract/ocr.js +164 -0
- package/dist/extract/pdf.js +127 -0
- package/dist/extract/presets/index.js +18 -0
- package/dist/extract/presets/lighton-ocr.js +12 -0
- package/dist/extract/presets/typhoon-ocr.js +23 -0
- package/dist/extract/route.js +40 -0
- package/dist/extract/source.js +84 -0
- package/dist/ingest/commit.js +313 -0
- package/dist/ingest/dedup.js +35 -0
- package/dist/ingest/prepare.js +241 -0
- package/dist/ingest/vault.js +56 -0
- package/dist/lib/date.js +6 -0
- package/dist/lib/ids.js +25 -0
- package/dist/lib/json.js +12 -0
- package/dist/lib/masked.js +49 -0
- package/dist/lib/money.js +35 -0
- package/dist/lib/patch.js +29 -0
- package/dist/lib/result.js +15 -0
- package/dist/lib/validate.js +154 -0
- package/dist/privacy/redactor.js +140 -0
- package/dist/setup/hosts.js +32 -0
- package/dist/setup/install.js +60 -0
- package/package.json +60 -0
- package/skills/SKILL.md +18 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, } from "fs";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
import { resolve } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
const OLED_DIR = process.env.OLED_DIR
|
|
7
|
+
? resolve(process.env.OLED_DIR)
|
|
8
|
+
: resolve(homedir(), ".oled");
|
|
9
|
+
/**
|
|
10
|
+
* Also drives the persisted-key list: unknown keys on disk are tolerated on
|
|
11
|
+
* read and dropped on the next write — `saveConfig` writes only the fields
|
|
12
|
+
* listed here.
|
|
13
|
+
*/
|
|
14
|
+
const CONFIG_FIELDS = {
|
|
15
|
+
// Last-resort constants; `config converge` overrides them — other modules
|
|
16
|
+
// should read the resolved value, not hardcode a currency.
|
|
17
|
+
displayLocale: { default: "th-TH" },
|
|
18
|
+
displayCurrency: { default: "THB" },
|
|
19
|
+
dbPath: { envVar: "OLED_DB_PATH", default: resolve(OLED_DIR, "db.sqlite") },
|
|
20
|
+
dbEncryptionKey: { envVar: "OLED_DB_ENCRYPTION_KEY", default: "" },
|
|
21
|
+
dataDir: { envVar: "OLED_DATA_DIR", default: resolve(OLED_DIR, "data") },
|
|
22
|
+
userName: { default: "User" },
|
|
23
|
+
ocrBaseUrl: { envVar: "OLED_OCR_BASE_URL", default: "" },
|
|
24
|
+
// Blank means the preset registry's own default model; src/extract/presets/ owns the id.
|
|
25
|
+
ocrModel: { envVar: "OLED_OCR_MODEL", default: "" },
|
|
26
|
+
ocrApiKey: { envVar: "OLED_OCR_API_KEY", default: "" },
|
|
27
|
+
};
|
|
28
|
+
const CONFIG_KEYS = Object.keys(CONFIG_FIELDS);
|
|
29
|
+
/** Config fields whose value must never be echoed in plaintext; `config show`
|
|
30
|
+
* renders each as `{ set, fingerprint }` via `keyFingerprint()` instead. */
|
|
31
|
+
export const CONFIG_SECRETS = ["dbEncryptionKey", "ocrApiKey"];
|
|
32
|
+
export function getOledDir() {
|
|
33
|
+
return OLED_DIR;
|
|
34
|
+
}
|
|
35
|
+
export function getConfigPath() {
|
|
36
|
+
return resolve(OLED_DIR, "config.json");
|
|
37
|
+
}
|
|
38
|
+
export function getDataDir() {
|
|
39
|
+
return config.dataDir;
|
|
40
|
+
}
|
|
41
|
+
/** Scratch space for extracted text and page images; env-overridable for tests. */
|
|
42
|
+
export function getCacheDir() {
|
|
43
|
+
return process.env.OLED_CACHE_DIR || resolve(OLED_DIR, "cache");
|
|
44
|
+
}
|
|
45
|
+
/** Non-reversible fingerprint (`sha256:` + first 8 hex) so `config`/`status`
|
|
46
|
+
* can prove a key is set without ever printing the passphrase. */
|
|
47
|
+
export function keyFingerprint(key) {
|
|
48
|
+
return `sha256:${createHash("sha256").update(key).digest("hex").slice(0, 8)}`;
|
|
49
|
+
}
|
|
50
|
+
function loadFileConfig() {
|
|
51
|
+
const configPath = getConfigPath();
|
|
52
|
+
if (!existsSync(configPath))
|
|
53
|
+
return {};
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Must degrade to defaults, not throw — every command, including the
|
|
59
|
+
// ones that would repair the file, would crash at startup otherwise.
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function pickConfigFields(obj) {
|
|
64
|
+
const out = {};
|
|
65
|
+
for (const key of CONFIG_KEYS) {
|
|
66
|
+
if (obj[key] !== undefined)
|
|
67
|
+
out[key] = obj[key];
|
|
68
|
+
}
|
|
69
|
+
return out;
|
|
70
|
+
}
|
|
71
|
+
function buildConfig() {
|
|
72
|
+
const file = loadFileConfig();
|
|
73
|
+
const out = {};
|
|
74
|
+
// Precedence env > file > default. `||` (not `??`) so an empty-string value falls through too.
|
|
75
|
+
for (const key of CONFIG_KEYS) {
|
|
76
|
+
const { envVar, default: fallback } = CONFIG_FIELDS[key];
|
|
77
|
+
out[key] = (envVar && process.env[envVar]) || file[key] || fallback;
|
|
78
|
+
}
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
export const config = buildConfig();
|
|
82
|
+
/**
|
|
83
|
+
* File values only — no env overrides, no defaults folded in. Converge uses
|
|
84
|
+
* this to tell an explicitly-persisted value apart from a defaulted one, so
|
|
85
|
+
* it can slot a dataset-derived default between the two.
|
|
86
|
+
*/
|
|
87
|
+
export function loadPersistedConfig() {
|
|
88
|
+
return pickConfigFields(loadFileConfig());
|
|
89
|
+
}
|
|
90
|
+
export function saveConfig(partial) {
|
|
91
|
+
const configPath = getConfigPath();
|
|
92
|
+
if (!existsSync(OLED_DIR))
|
|
93
|
+
mkdirSync(OLED_DIR, { recursive: true });
|
|
94
|
+
const existing = loadFileConfig();
|
|
95
|
+
const merged = pickConfigFields({ ...existing, ...partial });
|
|
96
|
+
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", {
|
|
97
|
+
mode: 0o600,
|
|
98
|
+
});
|
|
99
|
+
try {
|
|
100
|
+
chmodSync(configPath, 0o600);
|
|
101
|
+
}
|
|
102
|
+
catch { }
|
|
103
|
+
Object.assign(config, merged);
|
|
104
|
+
}
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from "fs";
|
|
2
|
+
import { dirname, resolve } from "path";
|
|
3
|
+
import { getOledDir } from "./config.js";
|
|
4
|
+
import { tryExecute } from "./lib/result.js";
|
|
5
|
+
export function getContextPath() {
|
|
6
|
+
return resolve(getOledDir(), "context.md");
|
|
7
|
+
}
|
|
8
|
+
export function readContext() {
|
|
9
|
+
const p = getContextPath();
|
|
10
|
+
if (!existsSync(p))
|
|
11
|
+
return "";
|
|
12
|
+
const result = tryExecute(() => readFileSync(p, "utf-8"));
|
|
13
|
+
return result.ok ? result.value : "";
|
|
14
|
+
}
|
|
15
|
+
function writeContext(content) {
|
|
16
|
+
const p = getContextPath();
|
|
17
|
+
const dir = dirname(p);
|
|
18
|
+
if (!existsSync(dir))
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
writeFileSync(p, content, { encoding: "utf-8", mode: 0o600 });
|
|
21
|
+
try {
|
|
22
|
+
chmodSync(p, 0o600);
|
|
23
|
+
}
|
|
24
|
+
catch { }
|
|
25
|
+
}
|
|
26
|
+
export function createContextTemplate(userName) {
|
|
27
|
+
if (existsSync(getContextPath()))
|
|
28
|
+
return;
|
|
29
|
+
writeContext(`# OpenLedger context for ${userName}\n\n## Family\n- ${userName}\n\n## Income\n- (Optional: add your primary income source so OpenLedger can mark it as PII when sending data to the model.)\n\n## Notes\n- (Free-form notes about your accounts, bank preferences, or anything OpenLedger should keep in mind when ingesting.)\n`);
|
|
30
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { loadDatasetRows } from "./loader.js";
|
|
3
|
+
// Locale + currency per country, one `<cc>.json` file under `datasets/defaults/`.
|
|
4
|
+
// `config converge` seeds display defaults from it.
|
|
5
|
+
const countryDefaultsSchema = z.object({
|
|
6
|
+
country: z.string(),
|
|
7
|
+
locale: z.string(),
|
|
8
|
+
currency: z.string(),
|
|
9
|
+
});
|
|
10
|
+
export const defaultsDataset = {
|
|
11
|
+
dirname: "defaults",
|
|
12
|
+
schema: countryDefaultsSchema,
|
|
13
|
+
// The file's `country` is re-added by the loader (uppercased); the row carries
|
|
14
|
+
// only the display fields here to avoid duplicating it.
|
|
15
|
+
flatten: (file) => [{ locale: file.locale, currency: file.currency }],
|
|
16
|
+
sortKey: (row) => row.country,
|
|
17
|
+
};
|
|
18
|
+
function all() {
|
|
19
|
+
return loadDatasetRows("defaults", defaultsDataset);
|
|
20
|
+
}
|
|
21
|
+
/** The locale/currency defaults for a country (case-insensitive), or null. */
|
|
22
|
+
export function findCountryDefaults(country) {
|
|
23
|
+
const cc = country.toUpperCase();
|
|
24
|
+
return all().find((r) => r.country === cc) ?? null;
|
|
25
|
+
}
|
|
26
|
+
/** Uppercased country codes that have defaults, sorted — for "unknown country" hints. */
|
|
27
|
+
export function availableCountries() {
|
|
28
|
+
return all().map((r) => r.country);
|
|
29
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { loadDatasetRows } from "./loader.js";
|
|
2
|
+
import { institutionsDataset } from "./institutions.js";
|
|
3
|
+
import { defaultsDataset } from "./defaults.js";
|
|
4
|
+
/** A dataset with a typed finder of its own (`findCountryDefaults`) exports it from its own module instead. */
|
|
5
|
+
// `any` here only erases the per-entry file shape (institutions vs defaults each
|
|
6
|
+
// have their own concrete `DatasetDefinition<...>`), which the registry doesn't need.
|
|
7
|
+
const REGISTRY = {
|
|
8
|
+
institutions: institutionsDataset,
|
|
9
|
+
defaults: defaultsDataset,
|
|
10
|
+
};
|
|
11
|
+
export function listDatasetNames() {
|
|
12
|
+
return Object.keys(REGISTRY);
|
|
13
|
+
}
|
|
14
|
+
/** Drives the CLI `--kind` guard. */
|
|
15
|
+
export function datasetHasKinds(name) {
|
|
16
|
+
return !!REGISTRY[name]?.kinds;
|
|
17
|
+
}
|
|
18
|
+
export function listDatasets() {
|
|
19
|
+
return Object.entries(REGISTRY).map(([name, def]) => {
|
|
20
|
+
const rows = loadDatasetRows(name, def);
|
|
21
|
+
const countries = [...new Set(rows.map((r) => r.country))].sort();
|
|
22
|
+
return { name, countries, rows: rows.length };
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
/** Throws on an unknown name — the CLI validates the name first for a clean error. */
|
|
26
|
+
export function readDataset(name, filter = {}) {
|
|
27
|
+
const def = REGISTRY[name];
|
|
28
|
+
if (!def)
|
|
29
|
+
throw new Error(`unknown dataset "${name}"`);
|
|
30
|
+
const country = filter.country?.toUpperCase();
|
|
31
|
+
const { kind } = filter;
|
|
32
|
+
return loadDatasetRows(name, def).filter((r) => (!country || r.country === country) && (!kind || r.kind === kind));
|
|
33
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
const INSTITUTION_KINDS = [
|
|
3
|
+
"bank",
|
|
4
|
+
"card_issuer",
|
|
5
|
+
"wallet",
|
|
6
|
+
"payment_rail",
|
|
7
|
+
"broker",
|
|
8
|
+
"crypto_exchange",
|
|
9
|
+
"insurer",
|
|
10
|
+
"gov",
|
|
11
|
+
"telco",
|
|
12
|
+
"utility",
|
|
13
|
+
];
|
|
14
|
+
const institutionSchema = z.object({
|
|
15
|
+
code: z.string(),
|
|
16
|
+
label: z.string(),
|
|
17
|
+
kind: z.enum(INSTITUTION_KINDS),
|
|
18
|
+
notes: z.string().optional(),
|
|
19
|
+
});
|
|
20
|
+
/** Exported so the loader test can exercise validation directly without writing a malformed file to disk. */
|
|
21
|
+
export const countryFileSchema = z.object({
|
|
22
|
+
country: z.string(),
|
|
23
|
+
institutions: z.array(institutionSchema),
|
|
24
|
+
});
|
|
25
|
+
export const institutionsDataset = {
|
|
26
|
+
dirname: "institutions",
|
|
27
|
+
schema: countryFileSchema,
|
|
28
|
+
flatten: (file) => file.institutions,
|
|
29
|
+
sortKey: (row) => String(row.code ?? ""),
|
|
30
|
+
kinds: INSTITUTION_KINDS,
|
|
31
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync, readdirSync } from "node:fs";
|
|
2
|
+
import { resolve, dirname } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
/**
|
|
5
|
+
* Generic loader for the shipped reference datasets: one subdirectory per
|
|
6
|
+
* dataset under `datasets/`, one `<cc>.json` per country. Adding a country is
|
|
7
|
+
* a new file, not a code change — dataset-specific shape lives in the
|
|
8
|
+
* per-dataset modules (institutions.ts, defaults.ts).
|
|
9
|
+
*/
|
|
10
|
+
// Two levels below the package root either way (src/datasets/ under tsx, dist/datasets/
|
|
11
|
+
// built); the uncompiled datasets/ dir is reached by the same relative walk in both.
|
|
12
|
+
const DATASETS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "../../datasets");
|
|
13
|
+
// Memoized per dataset name so importing a dataset module does no file I/O.
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
function readCountryFile(def, file) {
|
|
16
|
+
const path = resolve(DATASETS_DIR, def.dirname, file);
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
// A shipped dataset file that won't parse is a packaging defect, not user
|
|
23
|
+
// input — surface it loudly rather than degrading to an empty registry.
|
|
24
|
+
throw new Error(`dataset file ${def.dirname}/${file} is not valid JSON: ${err.message}`);
|
|
25
|
+
}
|
|
26
|
+
const parsed = def.schema.safeParse(raw);
|
|
27
|
+
if (!parsed.success) {
|
|
28
|
+
const detail = parsed.error.issues
|
|
29
|
+
.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
|
|
30
|
+
.join("; ");
|
|
31
|
+
throw new Error(`dataset file ${def.dirname}/${file} has an invalid shape: ${detail}`);
|
|
32
|
+
}
|
|
33
|
+
const country = parsed.data.country.toUpperCase();
|
|
34
|
+
return def.flatten(parsed.data).map((row) => ({ ...row, country }));
|
|
35
|
+
}
|
|
36
|
+
function loadAll(def) {
|
|
37
|
+
const dir = resolve(DATASETS_DIR, def.dirname);
|
|
38
|
+
const files = readdirSync(dir)
|
|
39
|
+
.filter((f) => f.endsWith(".json"))
|
|
40
|
+
.sort();
|
|
41
|
+
const rows = files.flatMap((f) => readCountryFile(def, f));
|
|
42
|
+
rows.sort((a, b) => a.country.localeCompare(b.country) ||
|
|
43
|
+
(def.sortKey ? def.sortKey(a).localeCompare(def.sortKey(b)) : 0));
|
|
44
|
+
return rows;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Every row of a named dataset, sorted by country then the sort key. Returns
|
|
48
|
+
* the shared memoized array — callers must copy before mutating it.
|
|
49
|
+
*/
|
|
50
|
+
export function loadDatasetRows(name, def) {
|
|
51
|
+
const cached = cache.get(name);
|
|
52
|
+
if (cached)
|
|
53
|
+
return cached;
|
|
54
|
+
const rows = loadAll(def);
|
|
55
|
+
cache.set(name, rows);
|
|
56
|
+
return rows;
|
|
57
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import Database from "libsql";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { migrate } from "./schema.js";
|
|
4
|
+
import { dirname } from "path";
|
|
5
|
+
import { mkdirSync, existsSync, chmodSync } from "fs";
|
|
6
|
+
let singleDb = null;
|
|
7
|
+
function openDb(dbPath, encryptionKey) {
|
|
8
|
+
const dir = dirname(dbPath);
|
|
9
|
+
if (!existsSync(dir))
|
|
10
|
+
mkdirSync(dir, { recursive: true });
|
|
11
|
+
const opts = {};
|
|
12
|
+
if (encryptionKey) {
|
|
13
|
+
opts.encryptionCipher = "aes256cbc";
|
|
14
|
+
opts.encryptionKey = encryptionKey;
|
|
15
|
+
}
|
|
16
|
+
const db = new Database(dbPath, opts);
|
|
17
|
+
try {
|
|
18
|
+
db.pragma("journal_mode = WAL");
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
db.close();
|
|
22
|
+
// Remaps into a NOT_READY-matching message (see NOT_READY_PATTERNS in cli/output.ts).
|
|
23
|
+
throw new Error("Failed to open database. Wrong encryption key or corrupt database file. " +
|
|
24
|
+
"If you changed your encryption key, restore from backup or delete ~/.oled/db.sqlite to start fresh.", { cause: err });
|
|
25
|
+
}
|
|
26
|
+
db.pragma("foreign_keys = ON");
|
|
27
|
+
migrate(db, dbPath);
|
|
28
|
+
try {
|
|
29
|
+
chmodSync(dbPath, 0o600);
|
|
30
|
+
}
|
|
31
|
+
catch { }
|
|
32
|
+
return db;
|
|
33
|
+
}
|
|
34
|
+
export function getDb() {
|
|
35
|
+
if (!singleDb) {
|
|
36
|
+
singleDb = openDb(config.dbPath, config.dbEncryptionKey || undefined);
|
|
37
|
+
}
|
|
38
|
+
return singleDb;
|
|
39
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
|
2
|
+
const SECRET_KEY_SALT = "oled-secret-v1";
|
|
3
|
+
const FORMAT_PREFIX = "gcm:";
|
|
4
|
+
export function generateKey() {
|
|
5
|
+
return randomBytes(32).toString("hex");
|
|
6
|
+
}
|
|
7
|
+
function deriveSecretKey(dbKey) {
|
|
8
|
+
return scryptSync(dbKey, SECRET_KEY_SALT, 32);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Encrypts a secret (e.g. PDF password) with AES-256-GCM, key derived from
|
|
12
|
+
* `dbKey` via scrypt; a passthrough when `dbKey` is empty. Output format:
|
|
13
|
+
* `gcm:<iv-hex>:<tag-hex>:<ciphertext-hex>`.
|
|
14
|
+
*/
|
|
15
|
+
export function encryptSecret(plaintext, dbKey) {
|
|
16
|
+
if (!dbKey)
|
|
17
|
+
return plaintext;
|
|
18
|
+
const key = deriveSecretKey(dbKey);
|
|
19
|
+
const iv = randomBytes(12);
|
|
20
|
+
const cipher = createCipheriv("aes-256-gcm", key, iv);
|
|
21
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
22
|
+
const tag = cipher.getAuthTag();
|
|
23
|
+
return `${FORMAT_PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${ct.toString("hex")}`;
|
|
24
|
+
}
|
|
25
|
+
export function decryptSecret(ciphertext, dbKey) {
|
|
26
|
+
if (!dbKey || !ciphertext.startsWith(FORMAT_PREFIX))
|
|
27
|
+
return ciphertext;
|
|
28
|
+
const rest = ciphertext.slice(FORMAT_PREFIX.length);
|
|
29
|
+
const parts = rest.split(":");
|
|
30
|
+
if (parts.length !== 3) {
|
|
31
|
+
throw new Error("Malformed encrypted secret.");
|
|
32
|
+
}
|
|
33
|
+
const [ivHex, tagHex, ctHex] = parts;
|
|
34
|
+
const key = deriveSecretKey(dbKey);
|
|
35
|
+
const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(ivHex, "hex"));
|
|
36
|
+
decipher.setAuthTag(Buffer.from(tagHex, "hex"));
|
|
37
|
+
const pt = Buffer.concat([
|
|
38
|
+
decipher.update(Buffer.from(ctHex, "hex")),
|
|
39
|
+
decipher.final(),
|
|
40
|
+
]);
|
|
41
|
+
return pt.toString("utf8");
|
|
42
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export function up(db) {
|
|
2
|
+
db.exec(`
|
|
3
|
+
CREATE TABLE IF NOT EXISTS accounts (
|
|
4
|
+
id TEXT PRIMARY KEY,
|
|
5
|
+
name TEXT NOT NULL,
|
|
6
|
+
type TEXT NOT NULL CHECK(type IN ('asset','liability','income','expense','equity')),
|
|
7
|
+
parent_id TEXT REFERENCES accounts(id),
|
|
8
|
+
subtype TEXT,
|
|
9
|
+
bank_name TEXT,
|
|
10
|
+
account_number_masked TEXT,
|
|
11
|
+
currency TEXT NOT NULL DEFAULT 'THB',
|
|
12
|
+
due_day INTEGER,
|
|
13
|
+
statement_day INTEGER,
|
|
14
|
+
points_balance REAL,
|
|
15
|
+
metadata_json TEXT,
|
|
16
|
+
pii_flag INTEGER NOT NULL DEFAULT 0,
|
|
17
|
+
has_question INTEGER NOT NULL DEFAULT 0,
|
|
18
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
CREATE INDEX IF NOT EXISTS accounts_parent_idx ON accounts(parent_id);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS accounts_type_idx ON accounts(type);
|
|
23
|
+
|
|
24
|
+
CREATE TABLE IF NOT EXISTS merchants (
|
|
25
|
+
id TEXT PRIMARY KEY,
|
|
26
|
+
canonical_name TEXT NOT NULL UNIQUE,
|
|
27
|
+
default_account_id TEXT REFERENCES accounts(id),
|
|
28
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
CREATE TABLE IF NOT EXISTS merchant_aliases (
|
|
32
|
+
id TEXT PRIMARY KEY,
|
|
33
|
+
merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
|
|
34
|
+
normalized_pattern TEXT NOT NULL UNIQUE,
|
|
35
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
CREATE INDEX IF NOT EXISTS merchant_aliases_merchant_idx ON merchant_aliases(merchant_id);
|
|
39
|
+
|
|
40
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
41
|
+
id TEXT PRIMARY KEY,
|
|
42
|
+
path TEXT NOT NULL,
|
|
43
|
+
file_hash TEXT NOT NULL UNIQUE,
|
|
44
|
+
mime TEXT NOT NULL,
|
|
45
|
+
status TEXT NOT NULL CHECK(status IN ('pending','ingested','failed')),
|
|
46
|
+
ingested_at TEXT,
|
|
47
|
+
source TEXT,
|
|
48
|
+
error TEXT,
|
|
49
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE TABLE IF NOT EXISTS transactions (
|
|
53
|
+
id TEXT PRIMARY KEY,
|
|
54
|
+
group_id TEXT,
|
|
55
|
+
date TEXT NOT NULL,
|
|
56
|
+
description TEXT NOT NULL,
|
|
57
|
+
merchant_id TEXT REFERENCES merchants(id),
|
|
58
|
+
raw_descriptor TEXT,
|
|
59
|
+
source_file_id TEXT REFERENCES files(id) ON DELETE CASCADE,
|
|
60
|
+
source_page INTEGER,
|
|
61
|
+
debit_account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
62
|
+
credit_account_id TEXT NOT NULL REFERENCES accounts(id),
|
|
63
|
+
amount INTEGER NOT NULL,
|
|
64
|
+
currency TEXT NOT NULL DEFAULT 'THB',
|
|
65
|
+
code TEXT,
|
|
66
|
+
user_ref TEXT,
|
|
67
|
+
void_of TEXT,
|
|
68
|
+
has_question INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
70
|
+
CHECK (amount > 0),
|
|
71
|
+
CHECK (debit_account_id <> credit_account_id)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
CREATE INDEX IF NOT EXISTS transactions_date_idx ON transactions(date);
|
|
75
|
+
CREATE INDEX IF NOT EXISTS transactions_debit_account_idx ON transactions(debit_account_id);
|
|
76
|
+
CREATE INDEX IF NOT EXISTS transactions_credit_account_idx ON transactions(credit_account_id);
|
|
77
|
+
CREATE INDEX IF NOT EXISTS transactions_source_file_idx ON transactions(source_file_id);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS transactions_group_idx ON transactions(group_id);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS transactions_merchant_idx ON transactions(merchant_id);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS questions (
|
|
82
|
+
id TEXT PRIMARY KEY,
|
|
83
|
+
batch_id TEXT,
|
|
84
|
+
file_id TEXT REFERENCES files(id) ON DELETE CASCADE,
|
|
85
|
+
transaction_id TEXT REFERENCES transactions(id) ON DELETE CASCADE,
|
|
86
|
+
account_id TEXT REFERENCES accounts(id) ON DELETE CASCADE,
|
|
87
|
+
kind TEXT,
|
|
88
|
+
prompt TEXT NOT NULL,
|
|
89
|
+
options_json TEXT,
|
|
90
|
+
context_json TEXT,
|
|
91
|
+
answer TEXT,
|
|
92
|
+
resolved_at TEXT,
|
|
93
|
+
deferred_until TEXT,
|
|
94
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
CREATE INDEX IF NOT EXISTS questions_batch_idx ON questions(batch_id);
|
|
98
|
+
CREATE INDEX IF NOT EXISTS questions_deferred_idx ON questions(deferred_until);
|
|
99
|
+
|
|
100
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
101
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
102
|
+
content TEXT NOT NULL,
|
|
103
|
+
category TEXT NOT NULL CHECK(category IN ('rule','preference','fact')),
|
|
104
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
108
|
+
key TEXT PRIMARY KEY,
|
|
109
|
+
value TEXT NOT NULL
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
CREATE TABLE IF NOT EXISTS file_passwords (
|
|
113
|
+
id TEXT PRIMARY KEY,
|
|
114
|
+
pattern TEXT NOT NULL UNIQUE,
|
|
115
|
+
password_encrypted TEXT NOT NULL,
|
|
116
|
+
last_used_at TEXT,
|
|
117
|
+
use_count INTEGER NOT NULL DEFAULT 0,
|
|
118
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
119
|
+
);
|
|
120
|
+
`);
|
|
121
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { config } from "../../config.js";
|
|
2
|
+
import { parseJsonOrNull } from "../../lib/json.js";
|
|
3
|
+
import { normalizeMaskedAccountNumber } from "../../lib/masked.js";
|
|
4
|
+
import { buildPatch } from "../../lib/patch.js";
|
|
5
|
+
import { errorMessage } from "../../lib/result.js";
|
|
6
|
+
export const TOP_LEVEL_TYPES = [
|
|
7
|
+
"asset", "liability", "income", "expense", "equity",
|
|
8
|
+
];
|
|
9
|
+
export function findAccountById(db, id) {
|
|
10
|
+
return db.prepare(`SELECT * FROM accounts WHERE id = ?`).get(id) ?? null;
|
|
11
|
+
}
|
|
12
|
+
export function accountExists(db, id) {
|
|
13
|
+
return !!db.prepare(`SELECT 1 FROM accounts WHERE id = ? LIMIT 1`).get(id);
|
|
14
|
+
}
|
|
15
|
+
export function countAccounts(db) {
|
|
16
|
+
const row = db.prepare(`SELECT COUNT(*) AS n FROM accounts`).get();
|
|
17
|
+
return row.n;
|
|
18
|
+
}
|
|
19
|
+
export function listAccounts(db) {
|
|
20
|
+
return db.prepare(`SELECT * FROM accounts ORDER BY name`).all();
|
|
21
|
+
}
|
|
22
|
+
export function countChildAccounts(db, parentId) {
|
|
23
|
+
const row = db
|
|
24
|
+
.prepare(`SELECT COUNT(*) AS n FROM accounts WHERE parent_id = ?`)
|
|
25
|
+
.get(parentId);
|
|
26
|
+
return row.n;
|
|
27
|
+
}
|
|
28
|
+
export function getAccountSubtree(db, rootId) {
|
|
29
|
+
return db.prepare(`WITH RECURSIVE subtree AS (
|
|
30
|
+
SELECT * FROM accounts WHERE id = ?
|
|
31
|
+
UNION ALL
|
|
32
|
+
SELECT a.* FROM accounts a JOIN subtree s ON a.parent_id = s.id
|
|
33
|
+
)
|
|
34
|
+
SELECT * FROM subtree ORDER BY id`).all(rootId);
|
|
35
|
+
}
|
|
36
|
+
/** The currency a transaction leg inherits from its account. */
|
|
37
|
+
export function findAccountCurrency(db, id) {
|
|
38
|
+
const row = db.prepare(`SELECT currency FROM accounts WHERE id = ?`).get(id);
|
|
39
|
+
return row?.currency ?? null;
|
|
40
|
+
}
|
|
41
|
+
/** A duplicate id surfaces as an Error coded 'ACCOUNT_EXISTS'. Hierarchy
|
|
42
|
+
* invariants are the caller's (`createAccount`). */
|
|
43
|
+
export function insertAccount(db, input) {
|
|
44
|
+
try {
|
|
45
|
+
db.prepare(`INSERT INTO accounts (id, name, type, parent_id, subtype, bank_name, account_number_masked, currency, due_day, statement_day, metadata_json)
|
|
46
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(input.id, input.name, input.type, input.parent_id ?? null, input.subtype ?? null, input.bank_name ? String(input.bank_name).toUpperCase() : null, normalizeMaskedAccountNumber(input.account_number_masked), input.currency ?? config.displayCurrency, input.due_day ?? null, input.statement_day ?? null, input.metadata ? JSON.stringify(input.metadata) : null);
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
const message = errorMessage(err);
|
|
50
|
+
if (message.includes("UNIQUE")) {
|
|
51
|
+
const dup = new Error(`Account "${input.id}" already exists.`);
|
|
52
|
+
dup.code = "ACCOUNT_EXISTS";
|
|
53
|
+
throw dup;
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/** Currency and the rest deliberately take the schema defaults. */
|
|
59
|
+
export function insertStructuralAccount(db, row) {
|
|
60
|
+
db.prepare(`INSERT INTO accounts (id, name, type, parent_id) VALUES (?, ?, ?, ?)`).run(row.id, row.name, row.type, row.parent_id);
|
|
61
|
+
}
|
|
62
|
+
export function renameAccount(db, id, name) {
|
|
63
|
+
return db.prepare(`UPDATE accounts SET name = ? WHERE id = ?`).run(name, id).changes;
|
|
64
|
+
}
|
|
65
|
+
const ACCOUNT_PATCH = {
|
|
66
|
+
due_day: {},
|
|
67
|
+
statement_day: {},
|
|
68
|
+
points_balance: {},
|
|
69
|
+
account_number_masked: {
|
|
70
|
+
transform: (v) => normalizeMaskedAccountNumber(v),
|
|
71
|
+
},
|
|
72
|
+
bank_name: {
|
|
73
|
+
transform: (v) => (v == null ? null : String(v).toUpperCase()),
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
/** Returns before/after snapshots of touched fields; `metadata` is
|
|
77
|
+
* shallow-merged into the existing metadata_json blob. */
|
|
78
|
+
export function updateAccountMetadata(db, id, patch) {
|
|
79
|
+
const current = findAccountById(db, id);
|
|
80
|
+
if (!current)
|
|
81
|
+
throw new Error(`Account "${id}" not found.`);
|
|
82
|
+
const { sets, params, before, after } = buildPatch(ACCOUNT_PATCH, current, patch);
|
|
83
|
+
if (patch.metadata !== undefined) {
|
|
84
|
+
// A corrupt blob must error, not be silently overwritten with {}.
|
|
85
|
+
let existing = {};
|
|
86
|
+
if (current.metadata_json != null) {
|
|
87
|
+
const parsed = parseJsonOrNull(current.metadata_json);
|
|
88
|
+
if (parsed == null || typeof parsed !== "object") {
|
|
89
|
+
throw new Error(`Account "${id}" has unreadable metadata_json; refusing to overwrite it.`);
|
|
90
|
+
}
|
|
91
|
+
existing = parsed;
|
|
92
|
+
}
|
|
93
|
+
const merged = { ...existing, ...patch.metadata };
|
|
94
|
+
sets.push("metadata_json = ?");
|
|
95
|
+
params.push(JSON.stringify(merged));
|
|
96
|
+
before.metadata = existing;
|
|
97
|
+
after.metadata = merged;
|
|
98
|
+
}
|
|
99
|
+
if (sets.length === 0)
|
|
100
|
+
return { before, after };
|
|
101
|
+
params.push(id);
|
|
102
|
+
db.prepare(`UPDATE accounts SET ${sets.join(", ")} WHERE id = ?`).run(...params);
|
|
103
|
+
return { before, after };
|
|
104
|
+
}
|
|
105
|
+
/** Deletes the row alone; transaction/child guards are the caller's (src/accounts/accounts.ts). */
|
|
106
|
+
export function deleteAccount(db, id) {
|
|
107
|
+
return db.prepare(`DELETE FROM accounts WHERE id = ?`).run(id).changes > 0;
|
|
108
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Leg expansion only: the normal-balance rule and decimal conversion are the
|
|
2
|
+
// caller's, in src/accounts/balances.ts.
|
|
3
|
+
/** Debit + credit legs of every non-void transaction, one row per leg (`void_of`
|
|
4
|
+
* rows excluded so a merged mirror never double-counts). */
|
|
5
|
+
const TRANSACTION_LEGS = `SELECT debit_account_id AS acct, amount, date, 'D' AS side FROM transactions WHERE void_of IS NULL
|
|
6
|
+
UNION ALL
|
|
7
|
+
SELECT credit_account_id AS acct, amount, date, 'C' AS side FROM transactions WHERE void_of IS NULL`;
|
|
8
|
+
/** Per-account leg sums, including accounts with no legs at all (LEFT JOIN). */
|
|
9
|
+
export function getAccountLegSums(db, opts = {}) {
|
|
10
|
+
const params = [];
|
|
11
|
+
const where = [];
|
|
12
|
+
if (opts.type) {
|
|
13
|
+
where.push("a.type = ?");
|
|
14
|
+
params.push(opts.type);
|
|
15
|
+
}
|
|
16
|
+
if (opts.idOrParent) {
|
|
17
|
+
where.push("(a.id = ? OR a.parent_id = ?)");
|
|
18
|
+
params.push(opts.idOrParent, opts.idOrParent);
|
|
19
|
+
}
|
|
20
|
+
const whereSql = where.length ? `WHERE ${where.join(" AND ")}` : "";
|
|
21
|
+
return db
|
|
22
|
+
.prepare(`SELECT a.*,
|
|
23
|
+
COALESCE(SUM(CASE WHEN t.side = 'D' THEN t.amount ELSE 0 END), 0) AS sum_debit,
|
|
24
|
+
COALESCE(SUM(CASE WHEN t.side = 'C' THEN t.amount ELSE 0 END), 0) AS sum_credit
|
|
25
|
+
FROM accounts a
|
|
26
|
+
LEFT JOIN (${TRANSACTION_LEGS}) t ON t.acct = a.id
|
|
27
|
+
${whereSql}
|
|
28
|
+
GROUP BY a.id
|
|
29
|
+
ORDER BY a.type, a.id`)
|
|
30
|
+
.all(...params);
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Income and expense legs dated within `from`..`to`, netted per
|
|
34
|
+
* (type, currency) so each currency converts with its own exponent.
|
|
35
|
+
*/
|
|
36
|
+
export function getPeriodLegSums(db, from, to) {
|
|
37
|
+
return db
|
|
38
|
+
.prepare(`SELECT a.type AS type, a.currency AS currency,
|
|
39
|
+
SUM(CASE WHEN t.side = 'C' THEN t.amount ELSE -t.amount END) AS c_minus_d
|
|
40
|
+
FROM (${TRANSACTION_LEGS}) t
|
|
41
|
+
JOIN accounts a ON a.id = t.acct
|
|
42
|
+
WHERE t.date BETWEEN ? AND ? AND a.type IN ('income', 'expense')
|
|
43
|
+
GROUP BY a.type, a.currency`)
|
|
44
|
+
.all(from, to);
|
|
45
|
+
}
|
|
46
|
+
/** Leg sums for a set of accounts (a subtree), grouped by (type, currency). */
|
|
47
|
+
export function getLegSumsForAccounts(db, ids) {
|
|
48
|
+
// An empty IN () is a syntax error, and no accounts means no legs.
|
|
49
|
+
if (ids.length === 0)
|
|
50
|
+
return [];
|
|
51
|
+
const placeholders = ids.map(() => "?").join(",");
|
|
52
|
+
return db
|
|
53
|
+
.prepare(`SELECT a.type AS type, a.currency AS currency,
|
|
54
|
+
COALESCE(SUM(CASE WHEN t.side = 'D' THEN t.amount ELSE 0 END), 0) AS sum_debit,
|
|
55
|
+
COALESCE(SUM(CASE WHEN t.side = 'C' THEN t.amount ELSE 0 END), 0) AS sum_credit
|
|
56
|
+
FROM accounts a
|
|
57
|
+
LEFT JOIN (${TRANSACTION_LEGS}) t ON t.acct = a.id
|
|
58
|
+
WHERE a.id IN (${placeholders})
|
|
59
|
+
GROUP BY a.type, a.currency`)
|
|
60
|
+
.all(...ids);
|
|
61
|
+
}
|