@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
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { emitList, fail, runAction } from "../output.js";
|
|
2
|
+
import { listDatasets, readDataset, listDatasetNames, datasetHasKinds, } from "../../datasets/index.js";
|
|
3
|
+
const DATASET_COLUMNS = [
|
|
4
|
+
{ header: "Name", value: (d) => d.name },
|
|
5
|
+
{ header: "Countries", value: (d) => d.countries.join(", ") },
|
|
6
|
+
{ header: "Rows", value: (d) => String(d.rows), align: "right" },
|
|
7
|
+
];
|
|
8
|
+
const INSTITUTION_COLUMNS = [
|
|
9
|
+
{ header: "Code", value: (r) => String(r.code ?? "") },
|
|
10
|
+
{ header: "Label", value: (r) => String(r.label ?? "") },
|
|
11
|
+
{ header: "Kind", value: (r) => String(r.kind ?? "") },
|
|
12
|
+
{ header: "Country", value: (r) => r.country },
|
|
13
|
+
];
|
|
14
|
+
const DEFAULTS_COLUMNS = [
|
|
15
|
+
{ header: "Country", value: (r) => r.country },
|
|
16
|
+
{ header: "Locale", value: (r) => String(r.locale ?? "") },
|
|
17
|
+
{ header: "Currency", value: (r) => String(r.currency ?? "") },
|
|
18
|
+
];
|
|
19
|
+
// Falls back to the generic country column so an unmapped dataset still renders something.
|
|
20
|
+
const COLUMNS_BY_DATASET = {
|
|
21
|
+
institutions: INSTITUTION_COLUMNS,
|
|
22
|
+
defaults: DEFAULTS_COLUMNS,
|
|
23
|
+
};
|
|
24
|
+
const GENERIC_COLUMNS = [{ header: "Country", value: (r) => r.country }];
|
|
25
|
+
function datasets(name, opts) {
|
|
26
|
+
if (name === undefined) {
|
|
27
|
+
if (opts.country || opts.kind) {
|
|
28
|
+
fail("USAGE", "--country/--kind need a dataset name (e.g. `oled datasets institutions --country th`)");
|
|
29
|
+
}
|
|
30
|
+
emitList(listDatasets(), DATASET_COLUMNS);
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
const names = listDatasetNames();
|
|
34
|
+
if (!names.includes(name)) {
|
|
35
|
+
fail("NOT_FOUND", `unknown dataset "${name}"`, { hint: `known datasets: ${names.join(", ")}` });
|
|
36
|
+
}
|
|
37
|
+
if (opts.kind && !datasetHasKinds(name)) {
|
|
38
|
+
fail("USAGE", `dataset "${name}" has no kinds; drop --kind`);
|
|
39
|
+
}
|
|
40
|
+
const rows = readDataset(name, { country: opts.country, kind: opts.kind });
|
|
41
|
+
emitList(rows, COLUMNS_BY_DATASET[name] ?? GENERIC_COLUMNS);
|
|
42
|
+
}
|
|
43
|
+
export function registerDatasets(program) {
|
|
44
|
+
program
|
|
45
|
+
.command("datasets [name]")
|
|
46
|
+
.description("Reference datasets")
|
|
47
|
+
.option("--country <code>", "filter rows by country (e.g. th)")
|
|
48
|
+
.option("--kind <kind>", "filter institutions by kind")
|
|
49
|
+
.addHelpText("after", [
|
|
50
|
+
"",
|
|
51
|
+
"Behavior: read-only reference data, institution codes and per-country defaults behind account leaves.",
|
|
52
|
+
"Typical flow: bare datasets lists them; datasets institutions --country th filters a country's institutions.",
|
|
53
|
+
"Example: oled datasets institutions --country th --kind bank --json",
|
|
54
|
+
].join("\n"))
|
|
55
|
+
.action(runAction(datasets));
|
|
56
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { randomUUID } from "crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
|
|
4
|
+
import { join, resolve } from "path";
|
|
5
|
+
import { getConfigPath, getDataDir } from "../../config.js";
|
|
6
|
+
import { listMissingTables } from "../../db/schema.js";
|
|
7
|
+
import { openDb } from "../db.js";
|
|
8
|
+
import { getVersion } from "../../setup/install.js";
|
|
9
|
+
import { SKILL_HOSTS } from "../../setup/hosts.js";
|
|
10
|
+
import { EXIT, currentMode, emit, emitList, runAction } from "../output.js";
|
|
11
|
+
import { errorMessage } from "../../lib/result.js";
|
|
12
|
+
import { probeOcrEndpoint, resolveOcr } from "../../extract/ocr.js";
|
|
13
|
+
const HARD_CHECKS = new Set(["db_open", "schema_tables_present"]);
|
|
14
|
+
const REQUIRED_TABLES = ["accounts", "transactions", "questions"];
|
|
15
|
+
function configCheck() {
|
|
16
|
+
return { name: "config_exists", ok: existsSync(getConfigPath()) };
|
|
17
|
+
}
|
|
18
|
+
async function dbOpenCheck() {
|
|
19
|
+
try {
|
|
20
|
+
const db = await openDb();
|
|
21
|
+
return { check: { name: "db_open", ok: true }, db };
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return { check: { name: "db_open", ok: false, detail: errorMessage(err) }, db: null };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function dataDirWritableCheck() {
|
|
28
|
+
try {
|
|
29
|
+
const dir = getDataDir();
|
|
30
|
+
mkdirSync(dir, { recursive: true });
|
|
31
|
+
const probe = join(dir, `.doctor-probe-${randomUUID()}`);
|
|
32
|
+
writeFileSync(probe, "ok");
|
|
33
|
+
rmSync(probe, { force: true });
|
|
34
|
+
return { name: "data_dir_writable", ok: true };
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
return { name: "data_dir_writable", ok: false, detail: errorMessage(err) };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function mupdfCheck() {
|
|
41
|
+
try {
|
|
42
|
+
await import("mupdf");
|
|
43
|
+
return { name: "mupdf_loads", ok: true };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
return { name: "mupdf_loads", ok: false, detail: errorMessage(err) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function schemaTablesCheck(db) {
|
|
50
|
+
const name = "schema_tables_present";
|
|
51
|
+
if (!db)
|
|
52
|
+
return { name, ok: false, detail: "database not open" };
|
|
53
|
+
try {
|
|
54
|
+
const missing = listMissingTables(db, REQUIRED_TABLES);
|
|
55
|
+
return {
|
|
56
|
+
name,
|
|
57
|
+
ok: missing.length === 0,
|
|
58
|
+
detail: missing.length ? `missing: ${missing.join(", ")}` : undefined,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
return { name, ok: false, detail: errorMessage(err) };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export async function ocrEndpointCheck() {
|
|
66
|
+
const name = "ocr_endpoint";
|
|
67
|
+
const settings = resolveOcr();
|
|
68
|
+
if (!settings)
|
|
69
|
+
return { name, ok: true, detail: "not configured" };
|
|
70
|
+
const { baseUrl, model, preset } = settings;
|
|
71
|
+
const served = await probeOcrEndpoint(settings);
|
|
72
|
+
if (!served.ok)
|
|
73
|
+
return { name, ok: false, detail: `${baseUrl}: ${served.error}` };
|
|
74
|
+
if (!served.value.includes(model)) {
|
|
75
|
+
return {
|
|
76
|
+
name,
|
|
77
|
+
ok: false,
|
|
78
|
+
detail: `${baseUrl} does not serve ${model} (serving: ${served.value.join(", ") || "nothing"})`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { name, ok: true, detail: `${preset}/${model} at ${baseUrl}` };
|
|
82
|
+
}
|
|
83
|
+
async function runChecks() {
|
|
84
|
+
const checks = [];
|
|
85
|
+
checks.push(configCheck());
|
|
86
|
+
const { check: dbCheck, db } = await dbOpenCheck();
|
|
87
|
+
checks.push(dbCheck);
|
|
88
|
+
checks.push(dataDirWritableCheck());
|
|
89
|
+
checks.push(await mupdfCheck());
|
|
90
|
+
checks.push(schemaTablesCheck(db));
|
|
91
|
+
checks.push(skillPackCheck());
|
|
92
|
+
checks.push(await ocrEndpointCheck());
|
|
93
|
+
return checks;
|
|
94
|
+
}
|
|
95
|
+
/** Informational only; never a HARD_CHECK. */
|
|
96
|
+
function skillPackCheck() {
|
|
97
|
+
const name = "skill_pack";
|
|
98
|
+
const candidates = [];
|
|
99
|
+
for (const host of SKILL_HOSTS) {
|
|
100
|
+
candidates.push({
|
|
101
|
+
host: host.id,
|
|
102
|
+
scope: "project",
|
|
103
|
+
path: join(resolve(process.cwd(), host.projectDir), "open-ledger", "VERSION"),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
for (const host of SKILL_HOSTS) {
|
|
107
|
+
candidates.push({
|
|
108
|
+
host: host.id,
|
|
109
|
+
scope: "global",
|
|
110
|
+
path: join(host.globalDir(), "open-ledger", "VERSION"),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
const found = candidates.find((c) => existsSync(c.path));
|
|
114
|
+
if (!found)
|
|
115
|
+
return { name, ok: true, detail: "not installed" };
|
|
116
|
+
const installed = readFileSync(found.path, "utf8").trim();
|
|
117
|
+
const where = `${found.host}, ${found.scope}`;
|
|
118
|
+
const cli = getVersion();
|
|
119
|
+
if (installed !== cli) {
|
|
120
|
+
return {
|
|
121
|
+
name,
|
|
122
|
+
ok: false,
|
|
123
|
+
detail: `installed ${installed} (${where}), cli ${cli} — refresh the skill (oled setup --force) or upgrade the CLI (npm install -g @morroc/open-ledger@latest)`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
return { name, ok: true, detail: `installed ${installed} (${where})` };
|
|
127
|
+
}
|
|
128
|
+
const CHECK_COLUMNS = [
|
|
129
|
+
{ header: "Check", value: (r) => r.name },
|
|
130
|
+
{ header: "OK", value: (r) => (r.ok ? "yes" : "no") },
|
|
131
|
+
{ header: "Detail", value: (r) => r.detail ?? "" },
|
|
132
|
+
];
|
|
133
|
+
async function diagnoseEnvironment() {
|
|
134
|
+
const checks = await runChecks();
|
|
135
|
+
const ok = checks.filter((c) => HARD_CHECKS.has(c.name)).every((c) => c.ok);
|
|
136
|
+
const mode = currentMode();
|
|
137
|
+
if (mode.json) {
|
|
138
|
+
emit({ checks, ok });
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
emitList(checks, CHECK_COLUMNS);
|
|
142
|
+
const line = `overall: ${ok ? "ready" : "not ready"}`;
|
|
143
|
+
process.stdout.write((mode.color ? (ok ? chalk.green(line) : chalk.red(line)) : line) + "\n");
|
|
144
|
+
}
|
|
145
|
+
if (!ok)
|
|
146
|
+
process.exitCode = EXIT.NOT_READY;
|
|
147
|
+
}
|
|
148
|
+
export function registerDoctor(program) {
|
|
149
|
+
program
|
|
150
|
+
.command("doctor")
|
|
151
|
+
.description("Diagnose the harness environment")
|
|
152
|
+
.action(runAction(diagnoseEnvironment));
|
|
153
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { emitList, emitObject, fail, requireYes, runAction, } from "../output.js";
|
|
2
|
+
import { openDb } from "../db.js";
|
|
3
|
+
const FILE_COLUMNS = [
|
|
4
|
+
{ header: "Status", value: (r) => r.status },
|
|
5
|
+
{ header: "ID", value: (r) => r.id },
|
|
6
|
+
{ header: "Source", value: (r) => r.source ?? "-" },
|
|
7
|
+
{ header: "Ingested At", value: (r) => r.ingested_at ?? "-" },
|
|
8
|
+
{ header: "Path", value: (r) => r.path },
|
|
9
|
+
];
|
|
10
|
+
/** files.status enum; `new` belongs to `ingest list`, not here — filtering by it here would silently match nothing. */
|
|
11
|
+
const FILE_STATUSES = ["pending", "ingested", "failed"];
|
|
12
|
+
async function listFiles(opts) {
|
|
13
|
+
// Checked up front — an unrecognized status would otherwise silently return zero rows.
|
|
14
|
+
const { status } = opts;
|
|
15
|
+
if (status !== undefined && !FILE_STATUSES.includes(status)) {
|
|
16
|
+
fail("USAGE", `--status must be one of ${FILE_STATUSES.join("|")}, got "${status}"`);
|
|
17
|
+
}
|
|
18
|
+
const db = await openDb();
|
|
19
|
+
const { listFiles: queryFiles } = await import("../../db/queries/files.js");
|
|
20
|
+
const rows = queryFiles(db);
|
|
21
|
+
emitList(status ? rows.filter((r) => r.status === status) : rows, FILE_COLUMNS);
|
|
22
|
+
}
|
|
23
|
+
async function showFile(id) {
|
|
24
|
+
const db = await openDb();
|
|
25
|
+
const { findFileById } = await import("../../db/queries/files.js");
|
|
26
|
+
const row = findFileById(db, id);
|
|
27
|
+
if (!row)
|
|
28
|
+
fail("NOT_FOUND", `no file: ${id}`);
|
|
29
|
+
const { countTransactionsBySourceFile } = await import("../../db/queries/transactions.js");
|
|
30
|
+
const { countQuestions } = await import("../../db/queries/questions.js");
|
|
31
|
+
emitObject({
|
|
32
|
+
type: "file_detail",
|
|
33
|
+
...row,
|
|
34
|
+
transaction_count: countTransactionsBySourceFile(db, id),
|
|
35
|
+
open_question_count: countQuestions(db, { file_id: id }),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async function dropFile(id, opts) {
|
|
39
|
+
requireYes(opts, `dropping file ${id}`);
|
|
40
|
+
const db = await openDb();
|
|
41
|
+
const { deleteFile } = await import("../../db/queries/files.js");
|
|
42
|
+
const res = deleteFile(db, id);
|
|
43
|
+
if (!res.removed)
|
|
44
|
+
fail("NOT_FOUND", `no file: ${id}`);
|
|
45
|
+
// The extracted text describes a row that no longer exists, and nothing can
|
|
46
|
+
// reach it once the row is gone — same purge `ingest done`/`fail` do.
|
|
47
|
+
const { cleanCache } = await import("../../ingest/prepare.js");
|
|
48
|
+
const { removed } = cleanCache(id);
|
|
49
|
+
emitObject({
|
|
50
|
+
file_id: id,
|
|
51
|
+
removed_transactions: res.removedTransactions,
|
|
52
|
+
removed_questions: res.removedQuestions,
|
|
53
|
+
cache_removed: removed,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
export function registerFiles(program) {
|
|
57
|
+
const files = program.command("files").description("Browse ingested files (list / show / drop)");
|
|
58
|
+
files
|
|
59
|
+
.command("list")
|
|
60
|
+
.description("List ingested files")
|
|
61
|
+
.option("--status <status>", `filter by status (${FILE_STATUSES.join("|")})`)
|
|
62
|
+
.action(runAction(listFiles));
|
|
63
|
+
files
|
|
64
|
+
.command("show <id>")
|
|
65
|
+
.description("Show a file with its transaction and open-question counts")
|
|
66
|
+
.action(runAction(showFile));
|
|
67
|
+
files
|
|
68
|
+
.command("drop <id>")
|
|
69
|
+
.description("Drop a file and cascade-remove its transactions/questions")
|
|
70
|
+
.option("--yes", "skip confirmation")
|
|
71
|
+
.action(runAction(dropFile));
|
|
72
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { EXIT, asRecord, currentMode, emit, emitObject, emitSummary, fail, readStdinBatch } from "../output.js";
|
|
2
|
+
import { openDb } from "../db.js";
|
|
3
|
+
import { newBatchId } from "../../lib/ids.js";
|
|
4
|
+
import * as z from "zod";
|
|
5
|
+
import { safeParse, str, num, json } from "../../lib/validate.js";
|
|
6
|
+
// Delegates to the base hooks (so raise() still fires) while recording a typed event per side.
|
|
7
|
+
function makeRecordingHooks(base, events) {
|
|
8
|
+
return {
|
|
9
|
+
onCommitted: (id) => base.onCommitted(id),
|
|
10
|
+
onDirtyInput: (input, reason) => {
|
|
11
|
+
base.onDirtyInput(input, reason);
|
|
12
|
+
events.push({ kind: "dirty", reason });
|
|
13
|
+
},
|
|
14
|
+
onUnknownMerchant: (input, id, attemptedId) => {
|
|
15
|
+
base.onUnknownMerchant(input, id, attemptedId);
|
|
16
|
+
events.push({ kind: "unknown_merchant", attemptedId });
|
|
17
|
+
},
|
|
18
|
+
onPlaceholderAccount: (side, accountId, id) => {
|
|
19
|
+
base.onPlaceholderAccount(side, accountId, id);
|
|
20
|
+
events.push({ kind: "placeholder", side, accountId });
|
|
21
|
+
},
|
|
22
|
+
onUncategorizedFallback: (side, accountId, id) => {
|
|
23
|
+
base.onUncategorizedFallback(side, accountId, id);
|
|
24
|
+
events.push({ kind: "uncategorized", side, accountId });
|
|
25
|
+
},
|
|
26
|
+
onSimilarAccount: (side, originalId, matchedId, id) => {
|
|
27
|
+
base.onSimilarAccount(side, originalId, matchedId, id);
|
|
28
|
+
events.push({ kind: "fuzzy", side, originalId, matchedId });
|
|
29
|
+
},
|
|
30
|
+
onCurrencyMismatch: (input, debit, credit) => {
|
|
31
|
+
base.onCurrencyMismatch(input, debit, credit);
|
|
32
|
+
events.push({ kind: "currency_mismatch" });
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
// Maps the resolver's own vocabulary (via the hooks) to the reported one.
|
|
37
|
+
const SIDE_RESOLUTIONS = {
|
|
38
|
+
fuzzy: (event) => ({ resolved: event.matchedId, how: "fuzzy_matched" }),
|
|
39
|
+
placeholder: (event) => ({ resolved: event.accountId, how: "placeholder_created" }),
|
|
40
|
+
uncategorized: (event) => ({ resolved: event.accountId, how: "uncategorized_fallback" }),
|
|
41
|
+
};
|
|
42
|
+
// No event means the side resolved by exact match — every other route raises one.
|
|
43
|
+
function classifySide(requested, side, events) {
|
|
44
|
+
const event = events.find((e) => "side" in e && e.side === side);
|
|
45
|
+
if (!event)
|
|
46
|
+
return { resolved: requested, how: "exact" };
|
|
47
|
+
return SIDE_RESOLUTIONS[event.kind](event);
|
|
48
|
+
}
|
|
49
|
+
// A duplicate re-commit fires no hooks and the stored row may have been recategorized
|
|
50
|
+
// since, so report its stored accounts instead of re-deriving them.
|
|
51
|
+
function reportSides(deps, outcome, raw, events) {
|
|
52
|
+
const stored = outcome.duplicate
|
|
53
|
+
? deps.findTransactionById(deps.db, outcome.transactionId)
|
|
54
|
+
: null;
|
|
55
|
+
if (stored) {
|
|
56
|
+
return [
|
|
57
|
+
{ side: "debit", requested: raw.debit_account_id, resolved: stored.debit_account_id, how: "as_committed" },
|
|
58
|
+
{ side: "credit", requested: raw.credit_account_id, resolved: stored.credit_account_id, how: "as_committed" },
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
return [
|
|
62
|
+
{ side: "debit", requested: raw.debit_account_id, ...classifySide(raw.debit_account_id, "debit", events) },
|
|
63
|
+
{ side: "credit", requested: raw.credit_account_id, ...classifySide(raw.credit_account_id, "credit", events) },
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
function classifyMerchant(item, events, resolvedMerchantId) {
|
|
67
|
+
const hadMerchant = !!(item.merchant || item.merchant_id);
|
|
68
|
+
if (!hadMerchant)
|
|
69
|
+
return { how: "none" };
|
|
70
|
+
if (events.some((e) => e.kind === "unknown_merchant"))
|
|
71
|
+
return { how: "unknown" };
|
|
72
|
+
const mid = resolvedMerchantId();
|
|
73
|
+
return { how: "linked", merchant_id: mid ?? undefined };
|
|
74
|
+
}
|
|
75
|
+
// validateRawTransaction is the validity authority: missing fields default to "" and surface as dirty_input.
|
|
76
|
+
// amount is excluded so its raw type reaches the validator's typeof check, unconverted by num().
|
|
77
|
+
const LINKED_HEADER_SPEC = z.object({
|
|
78
|
+
date: str().default(""),
|
|
79
|
+
description: str().default(""),
|
|
80
|
+
raw_descriptor: str().nullable().default(null),
|
|
81
|
+
source_page: num().nullable().default(null),
|
|
82
|
+
merchant: json().nullable().default(null),
|
|
83
|
+
merchant_id: str().nullable().default(null),
|
|
84
|
+
group_id: str().nullable().default(null),
|
|
85
|
+
row_index: num().nullable().default(null),
|
|
86
|
+
});
|
|
87
|
+
const LINKED_LEG_SPEC = z.object({
|
|
88
|
+
debit_account_id: str().default(""),
|
|
89
|
+
credit_account_id: str().default(""),
|
|
90
|
+
currency: str().nullable().default(null),
|
|
91
|
+
description: str().optional(),
|
|
92
|
+
code: str().nullable().default(null),
|
|
93
|
+
});
|
|
94
|
+
const STANDALONE_SPEC = z.object({
|
|
95
|
+
id: str().optional(),
|
|
96
|
+
date: str().default(""),
|
|
97
|
+
description: str().default(""),
|
|
98
|
+
raw_descriptor: str().nullable().default(null),
|
|
99
|
+
source_page: num().nullable().default(null),
|
|
100
|
+
row_index: num().nullable().default(null),
|
|
101
|
+
merchant: json().nullable().default(null),
|
|
102
|
+
merchant_id: str().nullable().default(null),
|
|
103
|
+
debit_account_id: str().default(""),
|
|
104
|
+
credit_account_id: str().default(""),
|
|
105
|
+
currency: str().nullable().default(null),
|
|
106
|
+
code: str().nullable().default(null),
|
|
107
|
+
});
|
|
108
|
+
// debit/credit accept a snake_case synonym that isn't the camelCase auto-bridge.
|
|
109
|
+
const LEG_ALIASES = {
|
|
110
|
+
debit_account_id: ["debit_account"],
|
|
111
|
+
credit_account_id: ["credit_account"],
|
|
112
|
+
};
|
|
113
|
+
// file_hash feeds deterministic transaction-id derivation elsewhere; memoized per file id.
|
|
114
|
+
function makeFileHashCache(db, findFileById) {
|
|
115
|
+
const cache = new Map();
|
|
116
|
+
return (fileId) => {
|
|
117
|
+
if (!fileId)
|
|
118
|
+
return null;
|
|
119
|
+
if (!cache.has(fileId))
|
|
120
|
+
cache.set(fileId, findFileById(db, fileId)?.file_hash ?? null);
|
|
121
|
+
return cache.get(fileId) ?? null;
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// A pre-pipeline reject (bad JSON shape) never throws — it reuses the per-row failure shape.
|
|
125
|
+
function failRow(counters, index, message) {
|
|
126
|
+
return failOutcome(counters, index, { reason: "dirty_input", message, raisedQuestions: 0 });
|
|
127
|
+
}
|
|
128
|
+
function failOutcome(counters, index, outcome) {
|
|
129
|
+
counters.failed++;
|
|
130
|
+
return {
|
|
131
|
+
type: "result",
|
|
132
|
+
index,
|
|
133
|
+
ok: false,
|
|
134
|
+
reason: outcome.reason,
|
|
135
|
+
message: outcome.message,
|
|
136
|
+
raised_questions: outcome.raisedQuestions,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
// Header + >=1 linked legs, committed atomically as one group.
|
|
140
|
+
function commitCompoundRow(deps, row, linked) {
|
|
141
|
+
const { counters } = deps;
|
|
142
|
+
const parsedHeader = safeParse(LINKED_HEADER_SPEC, row.record);
|
|
143
|
+
if (!parsedHeader.ok)
|
|
144
|
+
return failRow(counters, row.index, parsedHeader.error);
|
|
145
|
+
const header = { ...parsedHeader.value, source_file_id: row.fileId };
|
|
146
|
+
const legs = [];
|
|
147
|
+
let legError;
|
|
148
|
+
for (const rawLeg of linked) {
|
|
149
|
+
const legRecord = asRecord(rawLeg);
|
|
150
|
+
if (!legRecord) {
|
|
151
|
+
legError = "each linked leg must be a JSON object.";
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
const parsedLeg = safeParse(LINKED_LEG_SPEC, legRecord, { aliases: LEG_ALIASES });
|
|
155
|
+
if (!parsedLeg.ok) {
|
|
156
|
+
legError = parsedLeg.error;
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
// Cast is a lie for malformed rows — validateRawTransaction rejects those.
|
|
160
|
+
legs.push({ ...parsedLeg.value, amount: legRecord.amount });
|
|
161
|
+
}
|
|
162
|
+
if (legError !== undefined)
|
|
163
|
+
return failRow(counters, row.index, legError);
|
|
164
|
+
const outcome = deps.commitLinkedTransactions(deps.db, row.ctx, header, legs, row.hooks);
|
|
165
|
+
counters.raisedTotal += outcome.raisedQuestions;
|
|
166
|
+
if (!outcome.ok)
|
|
167
|
+
return failOutcome(counters, row.index, outcome);
|
|
168
|
+
const allDuplicate = outcome.results.every((r) => r.duplicate);
|
|
169
|
+
if (allDuplicate)
|
|
170
|
+
counters.duplicates++;
|
|
171
|
+
else
|
|
172
|
+
counters.posted++;
|
|
173
|
+
return {
|
|
174
|
+
type: "result",
|
|
175
|
+
index: row.index,
|
|
176
|
+
ok: true,
|
|
177
|
+
group_id: outcome.group_id,
|
|
178
|
+
legs: outcome.results.map((r) => ({ transaction_id: r.id, duplicate: r.duplicate })),
|
|
179
|
+
duplicate: allDuplicate,
|
|
180
|
+
raised_questions: outcome.raisedQuestions,
|
|
181
|
+
merchant: classifyMerchant(parsedHeader.value, row.events, () => deps.findTransactionById(deps.db, outcome.results[0]?.id)?.merchant_id),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
function commitStandaloneRow(deps, row) {
|
|
185
|
+
const { counters } = deps;
|
|
186
|
+
const parsed = safeParse(STANDALONE_SPEC, row.record, { aliases: LEG_ALIASES });
|
|
187
|
+
if (!parsed.ok)
|
|
188
|
+
return failRow(counters, row.index, parsed.error);
|
|
189
|
+
// Cast is a lie for malformed rows — validateRawTransaction rejects those.
|
|
190
|
+
const raw = {
|
|
191
|
+
...parsed.value,
|
|
192
|
+
source_file_id: row.fileId,
|
|
193
|
+
amount: row.record.amount,
|
|
194
|
+
};
|
|
195
|
+
const outcome = deps.commitTransaction(deps.db, row.ctx, raw, row.hooks);
|
|
196
|
+
counters.raisedTotal += outcome.raisedQuestions;
|
|
197
|
+
if (!outcome.ok)
|
|
198
|
+
return failOutcome(counters, row.index, outcome);
|
|
199
|
+
if (outcome.duplicate)
|
|
200
|
+
counters.duplicates++;
|
|
201
|
+
else
|
|
202
|
+
counters.posted++;
|
|
203
|
+
return {
|
|
204
|
+
type: "result",
|
|
205
|
+
index: row.index,
|
|
206
|
+
ok: true,
|
|
207
|
+
transaction_id: outcome.transactionId,
|
|
208
|
+
duplicate: outcome.duplicate,
|
|
209
|
+
raised_questions: outcome.raisedQuestions,
|
|
210
|
+
merchant: classifyMerchant(parsed.value, row.events, () => deps.findTransactionById(deps.db, outcome.transactionId)?.merchant_id),
|
|
211
|
+
sides: reportSides(deps, outcome, raw, row.events),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
export async function commitIngest(opts) {
|
|
215
|
+
const items = await readStdinBatch(opts.input);
|
|
216
|
+
if (items.length === 0)
|
|
217
|
+
fail("USAGE", "no transaction data provided");
|
|
218
|
+
const db = await openDb();
|
|
219
|
+
const { commitTransaction, commitLinkedTransactions, defaultTransactionCommitHooks } = await import("../../ingest/commit.js");
|
|
220
|
+
const { findTransactionById } = await import("../../db/queries/transactions.js");
|
|
221
|
+
const { findFileById } = await import("../../db/queries/files.js");
|
|
222
|
+
// An unknown --file id must fail before insert: a nulled file hash would break the
|
|
223
|
+
// deterministic transaction ids that dedup relies on.
|
|
224
|
+
if (opts.file && !findFileById(db, opts.file)) {
|
|
225
|
+
fail("NOT_FOUND", `no ingest entry: ${opts.file}`, {
|
|
226
|
+
hint: "run `oled ingest list --json` for the file ids, or drop --file to commit rows with no source file",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
// Must be non-null: raise() no-ops when batchId is null, silently dropping every question.
|
|
230
|
+
const batchId = newBatchId();
|
|
231
|
+
const fileHashFor = makeFileHashCache(db, findFileById);
|
|
232
|
+
const counters = { posted: 0, duplicates: 0, failed: 0, raisedTotal: 0 };
|
|
233
|
+
const deps = {
|
|
234
|
+
db,
|
|
235
|
+
commitTransaction,
|
|
236
|
+
commitLinkedTransactions,
|
|
237
|
+
findTransactionById,
|
|
238
|
+
counters,
|
|
239
|
+
};
|
|
240
|
+
const results = [];
|
|
241
|
+
for (let index = 0; index < items.length; index++) {
|
|
242
|
+
const record = asRecord(items[index]);
|
|
243
|
+
if (!record) {
|
|
244
|
+
results.push(failRow(counters, index, "each transaction must be a JSON object."));
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
const fileId = (record.source_file_id ?? opts.file) ?? null;
|
|
248
|
+
const ctx = { batchId, fileId, fileHash: fileHashFor(fileId) };
|
|
249
|
+
const events = [];
|
|
250
|
+
const hooks = makeRecordingHooks(defaultTransactionCommitHooks(db, ctx), events);
|
|
251
|
+
const row = { record, index, fileId, ctx, events, hooks };
|
|
252
|
+
const linked = record.linked;
|
|
253
|
+
if (Array.isArray(linked) && linked.length > 0) {
|
|
254
|
+
results.push(commitCompoundRow(deps, row, linked));
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
results.push(commitStandaloneRow(deps, row));
|
|
258
|
+
}
|
|
259
|
+
const mode = currentMode();
|
|
260
|
+
if (mode.json) {
|
|
261
|
+
for (const r of results)
|
|
262
|
+
emit(r);
|
|
263
|
+
emitSummary({
|
|
264
|
+
batch_id: batchId,
|
|
265
|
+
posted: counters.posted,
|
|
266
|
+
duplicates: counters.duplicates,
|
|
267
|
+
failed: counters.failed,
|
|
268
|
+
raised_questions: counters.raisedTotal,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
for (const r of results)
|
|
273
|
+
emitObject(r);
|
|
274
|
+
process.stdout.write(`\nbatch ${batchId}: ${counters.posted} posted, ${counters.duplicates} duplicate(s), ${counters.failed} failed, ${counters.raisedTotal} question(s) raised\n`);
|
|
275
|
+
}
|
|
276
|
+
// Exit 7 only for genuine failures — duplicates are a successful no-op.
|
|
277
|
+
if (counters.failed > 0)
|
|
278
|
+
process.exitCode = EXIT.PARTIAL;
|
|
279
|
+
}
|