@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,203 @@
|
|
|
1
|
+
import { EXIT, currentMode, emit, emitList, emitObject, emitSummary, fail, readSecretFromStdin, runAction, } from "../output.js";
|
|
2
|
+
import { openDb } from "../db.js";
|
|
3
|
+
import { commitIngest } from "./ingest-commit.js";
|
|
4
|
+
/**
|
|
5
|
+
* The accepted-input facts, so help text can't drift from the enforcing table;
|
|
6
|
+
* source.ts avoids mupdf/libsql, so it's safe on the startup path.
|
|
7
|
+
*/
|
|
8
|
+
import { MAX_SOURCE_BYTES, SUPPORTED_EXTS } from "../../extract/source.js";
|
|
9
|
+
const INGEST_COLUMNS = [
|
|
10
|
+
{ header: "Status", value: (r) => r.status },
|
|
11
|
+
{ header: "Enc", value: (r) => (r.encrypted ? `yes(${r.vaultCandidates})` : "no") },
|
|
12
|
+
{ header: "File ID", value: (r) => r.fileId ?? "-" },
|
|
13
|
+
{ header: "Path", value: (r) => r.relPath },
|
|
14
|
+
{ header: "Note", value: (r) => r.note ?? "" },
|
|
15
|
+
];
|
|
16
|
+
async function listIngest(opts) {
|
|
17
|
+
const db = await openDb();
|
|
18
|
+
const { discoverFiles } = await import("../../ingest/prepare.js");
|
|
19
|
+
let regex;
|
|
20
|
+
if (opts.regex) {
|
|
21
|
+
try {
|
|
22
|
+
regex = new RegExp(opts.regex);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
fail("USAGE", `invalid --regex: ${err.message}`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const entries = await discoverFiles(db, { regex });
|
|
29
|
+
const counts = {
|
|
30
|
+
new: 0,
|
|
31
|
+
pending: 0,
|
|
32
|
+
ingested: 0,
|
|
33
|
+
failed: 0,
|
|
34
|
+
unreadable: 0,
|
|
35
|
+
};
|
|
36
|
+
for (const e of entries)
|
|
37
|
+
counts[e.status]++;
|
|
38
|
+
const total = entries.length;
|
|
39
|
+
const mode = currentMode();
|
|
40
|
+
if (mode.json) {
|
|
41
|
+
for (const e of entries) {
|
|
42
|
+
emit({
|
|
43
|
+
type: "file",
|
|
44
|
+
path: e.path,
|
|
45
|
+
rel_path: e.relPath,
|
|
46
|
+
hash: e.hash,
|
|
47
|
+
file_id: e.fileId,
|
|
48
|
+
status: e.status,
|
|
49
|
+
encrypted: e.encrypted,
|
|
50
|
+
vault_candidates: e.vaultCandidates,
|
|
51
|
+
note: e.note ?? null,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
emitSummary({ ...counts, total });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
emitList(entries, INGEST_COLUMNS);
|
|
58
|
+
if (mode.tty) {
|
|
59
|
+
process.stdout.write(`\n${counts.new} new, ${counts.pending} pending, ${counts.ingested} ingested, ${counts.failed} failed, ${counts.unreadable} unreadable (${total} total)\n`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
const PASSWORD_HINT = "pipe the password with --password-stdin, or store it once via `oled vault add <pattern>`";
|
|
63
|
+
const ACCEPTED_EXTS = SUPPORTED_EXTS.join(" ");
|
|
64
|
+
const SIZE_LIMIT = `${MAX_SOURCE_BYTES / (1024 * 1024)} MB`;
|
|
65
|
+
const PREPARE_FAILURES = {
|
|
66
|
+
not_found: { code: "NOT_FOUND", hint: "run `oled ingest list --json` to see what is discoverable" },
|
|
67
|
+
unsupported_extension: {
|
|
68
|
+
code: "USAGE",
|
|
69
|
+
hint: `supported: ${ACCEPTED_EXTS} — export other formats first`,
|
|
70
|
+
},
|
|
71
|
+
kind_mismatch: { code: "USAGE", hint: "the bytes disagree with the extension; re-export or rename" },
|
|
72
|
+
too_large: {
|
|
73
|
+
code: "INVALID",
|
|
74
|
+
hint: `the limit is ${SIZE_LIMIT} — split the file or export it smaller`,
|
|
75
|
+
},
|
|
76
|
+
unreadable: { code: "NOT_FOUND", hint: "check the path and its permissions" },
|
|
77
|
+
pdf_unreadable: { code: "INVALID", hint: "the PDF may be corrupt; re-download or re-export it" },
|
|
78
|
+
password_required: { code: "INPUT_REQUIRED", hint: PASSWORD_HINT },
|
|
79
|
+
wrong_password: { code: "INPUT_REQUIRED", hint: PASSWORD_HINT },
|
|
80
|
+
ocr_unreachable: { code: "NOT_READY", hint: "start the OCR server, or re-run with --no-ocr" },
|
|
81
|
+
ocr_rejected: {
|
|
82
|
+
code: "NOT_READY",
|
|
83
|
+
hint: "check the model against `oled doctor --json`, or re-run with --no-ocr",
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
/** What each route adds to the shared head: a document to read, or page images. */
|
|
87
|
+
const PREPARE_PAYLOAD = {
|
|
88
|
+
text: (result) => ({
|
|
89
|
+
...(result.model ? { ocr_model: result.model } : {}),
|
|
90
|
+
document: result.document,
|
|
91
|
+
pages: result.pages,
|
|
92
|
+
// Only the OCR route reads page by page, so only it can lose one.
|
|
93
|
+
...(result.source === "ocr" ? { failed_pages: result.failedPages } : {}),
|
|
94
|
+
}),
|
|
95
|
+
images: (result) => ({ ...(result.dpi ? { dpi: result.dpi } : {}), pages: result.pages }),
|
|
96
|
+
};
|
|
97
|
+
async function prepareIngest(pathOrId, opts) {
|
|
98
|
+
const db = await openDb();
|
|
99
|
+
const { prepareFile } = await import("../../ingest/prepare.js");
|
|
100
|
+
const password = opts.passwordStdin ? await readSecretFromStdin() : undefined;
|
|
101
|
+
const result = await prepareFile(db, pathOrId, {
|
|
102
|
+
password,
|
|
103
|
+
force: !!opts.force,
|
|
104
|
+
rescan: !!opts.rescan,
|
|
105
|
+
noOcr: opts.ocr === false,
|
|
106
|
+
});
|
|
107
|
+
if (!result.ok) {
|
|
108
|
+
const { code, hint } = PREPARE_FAILURES[result.reason];
|
|
109
|
+
fail(code, result.message, { hint });
|
|
110
|
+
}
|
|
111
|
+
emitObject({
|
|
112
|
+
file_id: result.fileId,
|
|
113
|
+
kind: result.kind,
|
|
114
|
+
source: result.source,
|
|
115
|
+
text_layer: result.textLayer,
|
|
116
|
+
page_count: result.pageCount,
|
|
117
|
+
...PREPARE_PAYLOAD[result.kind](result),
|
|
118
|
+
});
|
|
119
|
+
// A page the endpoint could not read is a hole in the document, not a failed run.
|
|
120
|
+
if (result.kind === "text" && result.failedPages.length > 0)
|
|
121
|
+
process.exitCode = EXIT.PARTIAL;
|
|
122
|
+
}
|
|
123
|
+
async function completeIngest(id, opts) {
|
|
124
|
+
const db = await openDb();
|
|
125
|
+
const { markFileIngested } = await import("../../db/queries/files.js");
|
|
126
|
+
const changes = markFileIngested(db, id, { source: opts.agent ?? "external" });
|
|
127
|
+
if (changes === 0)
|
|
128
|
+
fail("NOT_FOUND", `no ingest entry: ${id}`);
|
|
129
|
+
const { cleanCache } = await import("../../ingest/prepare.js");
|
|
130
|
+
const { removed } = cleanCache(id);
|
|
131
|
+
emitObject({ file_id: id, status: "ingested", cache_removed: removed });
|
|
132
|
+
}
|
|
133
|
+
async function failIngest(id, opts) {
|
|
134
|
+
if (!opts.error)
|
|
135
|
+
fail("USAGE", "`ingest fail` requires --error <text>");
|
|
136
|
+
const db = await openDb();
|
|
137
|
+
const { markFileFailed } = await import("../../db/queries/files.js");
|
|
138
|
+
const changes = markFileFailed(db, id, { source: opts.agent ?? "external", error: opts.error });
|
|
139
|
+
if (changes === 0)
|
|
140
|
+
fail("NOT_FOUND", `no ingest entry: ${id}`);
|
|
141
|
+
const { cleanCache } = await import("../../ingest/prepare.js");
|
|
142
|
+
const { removed } = cleanCache(id);
|
|
143
|
+
emitObject({ file_id: id, status: "failed", cache_removed: removed });
|
|
144
|
+
}
|
|
145
|
+
export function registerIngest(program) {
|
|
146
|
+
const ingest = program
|
|
147
|
+
.command("ingest")
|
|
148
|
+
.description("Ingest pipeline: list / prepare / commit / done / fail")
|
|
149
|
+
.addHelpText("after", [
|
|
150
|
+
"",
|
|
151
|
+
"Behavior: the statement pipeline, list the files waiting, prepare one for reading, commit its rows, mark it done or failed.",
|
|
152
|
+
"Typical flow: list, prepare <id>, read what prepare returns, commit --file <sf:id> with the rows on stdin (or --input <batch>), then done <sf:id>.",
|
|
153
|
+
`Accepts ${ACCEPTED_EXTS}, up to ${SIZE_LIMIT}. Locked PDFs exit 4: pass the password with --password-stdin, or store it once with vault add <pattern>.`,
|
|
154
|
+
"Example: oled ingest prepare statement.pdf --json",
|
|
155
|
+
].join("\n"));
|
|
156
|
+
ingest
|
|
157
|
+
.command("list")
|
|
158
|
+
.description("List items in the ingest pipeline")
|
|
159
|
+
.option("--regex <pattern>", "filter items by regex")
|
|
160
|
+
.action(runAction(listIngest));
|
|
161
|
+
ingest
|
|
162
|
+
.command("prepare <pathOrId>")
|
|
163
|
+
.description("Extract a statement file into text (or page images) to read")
|
|
164
|
+
.option("--password-stdin", "read the password for a locked PDF from stdin")
|
|
165
|
+
.option("--force", "re-register the file, dropping the prior ingest's rows and artifacts")
|
|
166
|
+
.option("--rescan", "ignore the text layer and read the page images instead")
|
|
167
|
+
.option("--no-ocr", "ignore the OCR server and return the page images to you")
|
|
168
|
+
.addHelpText("after", [
|
|
169
|
+
"",
|
|
170
|
+
'Behavior: reads the file once and returns text whenever it can. A PDF carrying its own text layer is extracted directly. Otherwise the pages become images — a scan is rasterized, a photo is taken as it lies — and the OCR server reads them when one is configured (`oled config --ocr-url <url>`); with none configured they come back to you. The reader is named in source: "text-layer" or "ocr" when kind is "text", "raster" or "original" when kind is "images".',
|
|
171
|
+
'Output kind "text": one `document` path to read. Inside it, pages are separated by `--- page N ---` markers; cite the row\'s page as source_page on commit. Page numbers count from 1 everywhere — the markers, the pages[] entries, and source_page.',
|
|
172
|
+
'Output kind "images": one path per page under pages[], in order — read them yourself.',
|
|
173
|
+
"Escape hatches: --rescan ignores a garbled text layer and reads the pages instead; --no-ocr ignores the OCR server and returns the page images. Both together always return images.",
|
|
174
|
+
"Exits: 2 the file type is not supported, 3 the OCR server is misconfigured or unreachable, 4 the PDF needs a password, 5 nothing at that path or id, 6 the file is too large or corrupt, 7 the OCR server failed on some pages — each carries a `[page N: OCR failed]` line in the document and is listed in failed_pages; re-run with --no-ocr to read those pages yourself.",
|
|
175
|
+
"Example: oled ingest prepare statement.pdf --json",
|
|
176
|
+
].join("\n"))
|
|
177
|
+
.action(runAction(prepareIngest));
|
|
178
|
+
ingest
|
|
179
|
+
.command("commit")
|
|
180
|
+
.description("Commit extracted transactions (NDJSON/JSON array via --input file or stdin) into the ledger")
|
|
181
|
+
.option("--file <id>", "default source file id for committed rows")
|
|
182
|
+
.option("--input <path>", "read the batch from an NDJSON/JSON file instead of stdin")
|
|
183
|
+
.addHelpText("after", [
|
|
184
|
+
"",
|
|
185
|
+
"Behavior: posts one batch of statement rows; each item resolves account hints, links merchants, and raises questions instead of failing.",
|
|
186
|
+
'Item: {"date":"YYYY-MM-DD","description":"...","debit_account":"expense:food","credit_account":"asset:bank:kbank","amount":135.00,"source_page":2,"row_index":0,"raw_descriptor":"<verbatim bank text>","merchant":{"canonical_name":"..."}}',
|
|
187
|
+
"Rules: amount > 0, direction comes from the two accounts, never a sign; account ids are hints (resolved exact, then fuzzy, then placeholder); set row_index + source_page and pass --file <sf:id> so a re-run is an idempotent duplicate:true no-op.",
|
|
188
|
+
"Compound rows (payslip, FX): replace debit/credit/amount with linked:[{debit_account,credit_account,amount},...] sharing one account; legs commit atomically under one group_id. Cross-currency rows become two linked legs through equity:conversion:<ccy>.",
|
|
189
|
+
"Output: one result per item, then a summary with batch_id/posted/duplicates/failed. Exit 7 = some rows failed; duplicate:true is a success.",
|
|
190
|
+
].join("\n"))
|
|
191
|
+
.action(runAction(commitIngest));
|
|
192
|
+
ingest
|
|
193
|
+
.command("done <id>")
|
|
194
|
+
.description("Mark an ingest item as done")
|
|
195
|
+
.option("--agent <name>", "name of the completing agent")
|
|
196
|
+
.action(runAction(completeIngest));
|
|
197
|
+
ingest
|
|
198
|
+
.command("fail <id>")
|
|
199
|
+
.description("Mark an ingest item as failed")
|
|
200
|
+
.option("--agent <name>", "name of the failing agent")
|
|
201
|
+
.option("--error <text>", "failure reason")
|
|
202
|
+
.action(runAction(failIngest));
|
|
203
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { emitList, emitObject, fail, mapNotFoundError, requireYes, runAction, } from "../output.js";
|
|
2
|
+
import { openDb } from "../db.js";
|
|
3
|
+
import { listMerchants as queryMerchants, findMerchantByAlias, findMerchantById, upsertMerchant as upsertMerchantRow, setMerchantDefaultAccount, clearMerchantDefaultAccount, mergeMerchants as mergeMerchantRows, } from "../../db/queries/merchants.js";
|
|
4
|
+
import { findAccountById } from "../../db/queries/accounts.js";
|
|
5
|
+
import { applyRedaction } from "../../privacy/redactor.js";
|
|
6
|
+
import * as z from "zod";
|
|
7
|
+
import { parseInput, str, bool } from "../../lib/validate.js";
|
|
8
|
+
// `canonical_name` is the only free-text field; ids and the default-account link are structured data left verbatim.
|
|
9
|
+
const MERCHANT_REDACT_FIELDS = ["canonical_name"];
|
|
10
|
+
const MERCHANT_COLUMNS = [
|
|
11
|
+
{ header: "ID", value: (m) => m.id },
|
|
12
|
+
{ header: "Name", value: (m) => m.canonical_name },
|
|
13
|
+
{ header: "Default Account", value: (m) => m.default_account_id ?? "" },
|
|
14
|
+
{ header: "Aliases", value: (m) => String(m.alias_count), align: "right" },
|
|
15
|
+
];
|
|
16
|
+
async function listMerchants(opts) {
|
|
17
|
+
const db = await openDb();
|
|
18
|
+
const rows = applyRedaction(queryMerchants(db), !!opts.redact, MERCHANT_REDACT_FIELDS);
|
|
19
|
+
emitList(rows, MERCHANT_COLUMNS);
|
|
20
|
+
}
|
|
21
|
+
const RESOLVE_MERCHANT_SPEC = z.object({ descriptor: str() });
|
|
22
|
+
async function resolveMerchant(opts) {
|
|
23
|
+
const parsed = parseInput(RESOLVE_MERCHANT_SPEC, opts);
|
|
24
|
+
const db = await openDb();
|
|
25
|
+
const match = findMerchantByAlias(db, parsed.descriptor);
|
|
26
|
+
if (!match) {
|
|
27
|
+
emitObject({ found: false });
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
emitObject({
|
|
31
|
+
found: true,
|
|
32
|
+
merchant_id: match.merchant.id,
|
|
33
|
+
canonical_name: match.merchant.canonical_name,
|
|
34
|
+
default_account_id: match.default_account_id,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const UPSERT_MERCHANT_SPEC = z.object({
|
|
38
|
+
name: str(),
|
|
39
|
+
alias: str().optional(),
|
|
40
|
+
default_account: str().optional(),
|
|
41
|
+
});
|
|
42
|
+
async function upsertMerchant(opts) {
|
|
43
|
+
const parsed = parseInput(UPSERT_MERCHANT_SPEC, opts);
|
|
44
|
+
const db = await openDb();
|
|
45
|
+
if (parsed.default_account && !findAccountById(db, parsed.default_account)) {
|
|
46
|
+
fail("NOT_FOUND", `account "${parsed.default_account}" not found`);
|
|
47
|
+
}
|
|
48
|
+
const input = { canonical_name: parsed.name };
|
|
49
|
+
if (parsed.alias)
|
|
50
|
+
input.alias = parsed.alias;
|
|
51
|
+
if (parsed.default_account)
|
|
52
|
+
input.default_account_id = parsed.default_account;
|
|
53
|
+
emitObject({ ...upsertMerchantRow(db, input) });
|
|
54
|
+
}
|
|
55
|
+
const SET_DEFAULT_SPEC = z.object({
|
|
56
|
+
merchant: str(),
|
|
57
|
+
account: str().optional(),
|
|
58
|
+
clear: bool().optional(),
|
|
59
|
+
});
|
|
60
|
+
async function setMerchantDefault(opts) {
|
|
61
|
+
const parsed = parseInput(SET_DEFAULT_SPEC, opts);
|
|
62
|
+
if (!!parsed.account === !!parsed.clear) {
|
|
63
|
+
fail("USAGE", "exactly one of --account or --clear is required");
|
|
64
|
+
}
|
|
65
|
+
const db = await openDb();
|
|
66
|
+
if (!findMerchantById(db, parsed.merchant)) {
|
|
67
|
+
fail("NOT_FOUND", `merchant "${parsed.merchant}" not found`);
|
|
68
|
+
}
|
|
69
|
+
if (parsed.clear) {
|
|
70
|
+
const result = clearMerchantDefaultAccount(db, parsed.merchant);
|
|
71
|
+
if (!result)
|
|
72
|
+
fail("NOT_FOUND", `merchant "${parsed.merchant}" not found`);
|
|
73
|
+
emitObject({ merchant_id: parsed.merchant, before: result.before, after: null });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!findAccountById(db, parsed.account)) {
|
|
77
|
+
fail("NOT_FOUND", `account "${parsed.account}" not found`);
|
|
78
|
+
}
|
|
79
|
+
const result = setMerchantDefaultAccount(db, parsed.merchant, parsed.account);
|
|
80
|
+
emitObject({ merchant_id: parsed.merchant, ...result });
|
|
81
|
+
}
|
|
82
|
+
const MERGE_MERCHANTS_SPEC = z.object({
|
|
83
|
+
from: str(),
|
|
84
|
+
to: str(),
|
|
85
|
+
});
|
|
86
|
+
async function mergeMerchants(opts) {
|
|
87
|
+
const parsed = parseInput(MERGE_MERCHANTS_SPEC, opts);
|
|
88
|
+
requireYes(opts, "merging merchants");
|
|
89
|
+
const db = await openDb();
|
|
90
|
+
let result;
|
|
91
|
+
try {
|
|
92
|
+
result = mergeMerchantRows(db, parsed.from, parsed.to);
|
|
93
|
+
}
|
|
94
|
+
catch (err) {
|
|
95
|
+
mapNotFoundError(err);
|
|
96
|
+
}
|
|
97
|
+
emitObject({ from: parsed.from, to: parsed.to, ...result });
|
|
98
|
+
}
|
|
99
|
+
export function registerMerchants(program) {
|
|
100
|
+
const merchants = program
|
|
101
|
+
.command("merchants")
|
|
102
|
+
.description("Manage merchants and their default accounts")
|
|
103
|
+
.addHelpText("after", [
|
|
104
|
+
"",
|
|
105
|
+
"Behavior: manages merchants and their default accounts; an alias maps raw bank text to a merchant.",
|
|
106
|
+
"Typical flow: resolve a descriptor; if unknown, upsert with a name and alias, then set-default.",
|
|
107
|
+
"Example: oled merchants resolve --descriptor \"POS STARBUCKS\" --json",
|
|
108
|
+
].join("\n"));
|
|
109
|
+
merchants
|
|
110
|
+
.command("list")
|
|
111
|
+
.description("List merchants")
|
|
112
|
+
.option("--no-redact", "skip PII redaction (on by default)")
|
|
113
|
+
.action(runAction(listMerchants));
|
|
114
|
+
merchants
|
|
115
|
+
.command("resolve")
|
|
116
|
+
.description("Resolve a merchant from a descriptor")
|
|
117
|
+
.option("--descriptor <text>", "raw transaction descriptor")
|
|
118
|
+
.action(runAction(resolveMerchant));
|
|
119
|
+
merchants
|
|
120
|
+
.command("upsert")
|
|
121
|
+
.description("Create or update a merchant")
|
|
122
|
+
.option("--name <name>", "merchant canonical name")
|
|
123
|
+
.option("--alias <alias>", "merchant alias to add")
|
|
124
|
+
.option("--default-account <id>", "default account id")
|
|
125
|
+
.action(runAction(upsertMerchant));
|
|
126
|
+
merchants
|
|
127
|
+
.command("set-default")
|
|
128
|
+
.description("Set or clear a merchant's default account")
|
|
129
|
+
.option("--merchant <id>", "merchant id")
|
|
130
|
+
.option("--account <id>", "account id")
|
|
131
|
+
.option("--clear", "clear the default account instead of setting one")
|
|
132
|
+
.action(runAction(setMerchantDefault));
|
|
133
|
+
merchants
|
|
134
|
+
.command("merge")
|
|
135
|
+
.description("Merge one merchant into another")
|
|
136
|
+
.option("--from <id>", "merchant id to merge from")
|
|
137
|
+
.option("--to <id>", "merchant id to merge into")
|
|
138
|
+
.option("--yes", "skip confirmation")
|
|
139
|
+
.action(runAction(mergeMerchants));
|
|
140
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { emitList, fail, requireYes, runAction } from "../output.js";
|
|
2
|
+
import { openDb } from "../db.js";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { parseInput, str, num } from "../../lib/validate.js";
|
|
5
|
+
const VALID_CATEGORIES = ["rule", "preference", "fact"];
|
|
6
|
+
// `content` is the only free-text field; id/category/created_at are
|
|
7
|
+
// structured data left verbatim.
|
|
8
|
+
const NOTE_REDACT_FIELDS = ["content"];
|
|
9
|
+
const NOTE_COLUMNS = [
|
|
10
|
+
{ header: "ID", value: (r) => String(r.id), align: "right" },
|
|
11
|
+
{ header: "Category", value: (r) => r.category },
|
|
12
|
+
{ header: "Content", value: (r) => r.content },
|
|
13
|
+
{ header: "Created At", value: (r) => r.created_at },
|
|
14
|
+
];
|
|
15
|
+
async function listNotes(opts) {
|
|
16
|
+
const { listNotes: queryNotes } = await import("../../db/queries/notes.js");
|
|
17
|
+
const db = await openDb();
|
|
18
|
+
let rows = queryNotes(db);
|
|
19
|
+
if (opts.redact) {
|
|
20
|
+
const { applyRedaction } = await import("../../privacy/redactor.js");
|
|
21
|
+
rows = applyRedaction(rows, true, NOTE_REDACT_FIELDS);
|
|
22
|
+
}
|
|
23
|
+
emitList(rows, NOTE_COLUMNS);
|
|
24
|
+
}
|
|
25
|
+
const ADD_NOTE_SPEC = z.object({
|
|
26
|
+
content: str(),
|
|
27
|
+
category: z.enum(VALID_CATEGORIES).default("fact"),
|
|
28
|
+
});
|
|
29
|
+
async function addNote(opts) {
|
|
30
|
+
const parsed = parseInput(ADD_NOTE_SPEC, opts);
|
|
31
|
+
const { listNotes: queryNotes, addNote: addNoteRow } = await import("../../db/queries/notes.js");
|
|
32
|
+
const db = await openDb();
|
|
33
|
+
addNoteRow(db, parsed.content, parsed.category);
|
|
34
|
+
const saved = queryNotes(db)
|
|
35
|
+
.filter((m) => m.content === parsed.content && m.category === parsed.category)
|
|
36
|
+
.sort((a, b) => b.id - a.id)[0];
|
|
37
|
+
emitList(saved ? [saved] : [], NOTE_COLUMNS);
|
|
38
|
+
}
|
|
39
|
+
// Positional `<id>` args aren't commander opts; parsed through the same spec
|
|
40
|
+
// API with an ad hoc raw object so the coercion message stays consistent.
|
|
41
|
+
const NOTE_ID_SPEC = z.object({ id: num() });
|
|
42
|
+
const NOTE_ID_LABELS = { id: "note id" };
|
|
43
|
+
async function removeNote(id, opts) {
|
|
44
|
+
requireYes(opts, "removing this note");
|
|
45
|
+
const parsed = parseInput(NOTE_ID_SPEC, { id }, { labels: NOTE_ID_LABELS });
|
|
46
|
+
const { deleteNote: deleteNoteRow } = await import("../../db/queries/notes.js");
|
|
47
|
+
const db = await openDb();
|
|
48
|
+
const deleted = deleteNoteRow(db, parsed.id);
|
|
49
|
+
if (!deleted)
|
|
50
|
+
fail("NOT_FOUND", `note "${id}" not found`);
|
|
51
|
+
emitList([deleted], NOTE_COLUMNS);
|
|
52
|
+
}
|
|
53
|
+
export function registerNotes(program) {
|
|
54
|
+
const notes = program.command("notes").description("Manage freeform notes");
|
|
55
|
+
notes
|
|
56
|
+
.command("list")
|
|
57
|
+
.description("List notes")
|
|
58
|
+
.option("--no-redact", "skip PII redaction (on by default)")
|
|
59
|
+
.action(runAction(listNotes));
|
|
60
|
+
notes
|
|
61
|
+
.command("add")
|
|
62
|
+
.description("Add a note")
|
|
63
|
+
.option("--content <text>", "note content")
|
|
64
|
+
.option("--category <cat>", "note category: rule, preference, or fact")
|
|
65
|
+
.action(runAction(addNote));
|
|
66
|
+
notes
|
|
67
|
+
.command("rm <id>")
|
|
68
|
+
.description("Remove a note")
|
|
69
|
+
.option("--yes", "skip confirmation")
|
|
70
|
+
.action(runAction(removeNote));
|
|
71
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, mkdirSync } from "fs";
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
import { getDataDir } from "../../config.js";
|
|
5
|
+
import { currentMode, emit, runAction } from "../output.js";
|
|
6
|
+
function openerCommand() {
|
|
7
|
+
switch (process.platform) {
|
|
8
|
+
case "darwin": return "open";
|
|
9
|
+
case "win32": return "explorer";
|
|
10
|
+
case "linux": return "xdg-open";
|
|
11
|
+
default: return null;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
// Never rejects: resolves with an error message on spawn failure, else undefined.
|
|
15
|
+
function spawnOpener(cmd, dataDir) {
|
|
16
|
+
return new Promise((resolvePromise) => {
|
|
17
|
+
const child = spawn(cmd, [dataDir], { stdio: "ignore", detached: true });
|
|
18
|
+
child.once("error", (err) => resolvePromise(err.message));
|
|
19
|
+
child.once("spawn", () => resolvePromise(undefined));
|
|
20
|
+
child.unref();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
// The path is reported even when the opener fails — it is still useful on its own.
|
|
24
|
+
async function openDataDir() {
|
|
25
|
+
const dataDir = getDataDir();
|
|
26
|
+
if (!existsSync(dataDir))
|
|
27
|
+
mkdirSync(dataDir, { recursive: true });
|
|
28
|
+
const cmd = openerCommand();
|
|
29
|
+
const spawnError = cmd
|
|
30
|
+
? await spawnOpener(cmd, dataDir)
|
|
31
|
+
: `don't know how to open the file manager on ${process.platform}`;
|
|
32
|
+
const mode = currentMode();
|
|
33
|
+
if (mode.json) {
|
|
34
|
+
const result = { path: dataDir };
|
|
35
|
+
if (spawnError)
|
|
36
|
+
result.spawn_error = spawnError;
|
|
37
|
+
emit(result);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (!mode.tty) {
|
|
41
|
+
process.stdout.write(dataDir + "\n");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
console.log(chalk.dim(`Data folder: ${dataDir}`));
|
|
45
|
+
if (spawnError) {
|
|
46
|
+
console.log(chalk.yellow(`Couldn't open the folder automatically: ${spawnError}. Open it manually with the path above.`));
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function registerOpen(program) {
|
|
50
|
+
program
|
|
51
|
+
.command("open")
|
|
52
|
+
.description("Open the data folder in file explorer")
|
|
53
|
+
.action(runAction(openDataDir));
|
|
54
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { emitList, fail, runAction } from "../output.js";
|
|
2
|
+
import { openDb } from "../db.js";
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
import { parseInput, str, int } from "../../lib/validate.js";
|
|
5
|
+
import { parseJsonOrNull } from "../../lib/json.js";
|
|
6
|
+
function toListRow(row) {
|
|
7
|
+
return {
|
|
8
|
+
id: row.id,
|
|
9
|
+
kind: row.kind,
|
|
10
|
+
prompt: row.prompt,
|
|
11
|
+
transaction_id: row.transaction_id,
|
|
12
|
+
account_id: row.account_id,
|
|
13
|
+
options: parseJsonOrNull(row.options_json),
|
|
14
|
+
context: parseJsonOrNull(row.context_json),
|
|
15
|
+
file_id: row.file_id,
|
|
16
|
+
created_at: row.created_at,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
// `prompt` is the only free-text field; options/context are structured JSON
|
|
20
|
+
// carrying ids the agent needs verbatim, so they're left intact.
|
|
21
|
+
const QUESTION_REDACT_FIELDS = ["prompt"];
|
|
22
|
+
const LIST_COLUMNS = [
|
|
23
|
+
{ header: "ID", value: (r) => r.id },
|
|
24
|
+
{ header: "Kind", value: (r) => r.kind ?? "" },
|
|
25
|
+
{ header: "Prompt", value: (r) => r.prompt },
|
|
26
|
+
{ header: "Transaction ID", value: (r) => r.transaction_id ?? "" },
|
|
27
|
+
{ header: "Account ID", value: (r) => r.account_id ?? "" },
|
|
28
|
+
{ header: "Options", value: (r) => (r.options != null ? JSON.stringify(r.options) : "") },
|
|
29
|
+
{ header: "Context", value: (r) => (r.context != null ? JSON.stringify(r.context) : "") },
|
|
30
|
+
{ header: "File ID", value: (r) => r.file_id ?? "" },
|
|
31
|
+
{ header: "Created At", value: (r) => r.created_at },
|
|
32
|
+
];
|
|
33
|
+
const ANSWERED_COLUMNS = [
|
|
34
|
+
{ header: "ID", value: (r) => r.id },
|
|
35
|
+
{ header: "Kind", value: (r) => r.kind ?? "" },
|
|
36
|
+
{ header: "Answer", value: (r) => r.answer },
|
|
37
|
+
{ header: "Rule Key", value: (r) => r.rule_key ?? "" },
|
|
38
|
+
];
|
|
39
|
+
const DEFER_COLUMNS = [
|
|
40
|
+
{ header: "ID", value: (r) => r.id },
|
|
41
|
+
{ header: "Days", value: (r) => String(r.days) },
|
|
42
|
+
];
|
|
43
|
+
async function listQuestions(opts) {
|
|
44
|
+
const { listQuestions: queryQuestions } = await import("../../db/queries/questions.js");
|
|
45
|
+
const db = await openDb();
|
|
46
|
+
const rows = queryQuestions(db, {
|
|
47
|
+
batchId: opts.batch,
|
|
48
|
+
includeDeferred: !!opts.includeDeferred,
|
|
49
|
+
});
|
|
50
|
+
let listRows = rows.map(toListRow);
|
|
51
|
+
if (opts.redact) {
|
|
52
|
+
const { applyRedaction } = await import("../../privacy/redactor.js");
|
|
53
|
+
listRows = applyRedaction(listRows, true, QUESTION_REDACT_FIELDS);
|
|
54
|
+
}
|
|
55
|
+
emitList(listRows, LIST_COLUMNS);
|
|
56
|
+
}
|
|
57
|
+
const ANSWER_SPEC = z.object({
|
|
58
|
+
answer: str(),
|
|
59
|
+
also: str().optional(),
|
|
60
|
+
});
|
|
61
|
+
async function answerQuestion(id, opts) {
|
|
62
|
+
const parsed = parseInput(ANSWER_SPEC, opts);
|
|
63
|
+
const also = parsed.also
|
|
64
|
+
? parsed.also.split(",").map((s) => s.trim()).filter(Boolean)
|
|
65
|
+
: [];
|
|
66
|
+
const ids = [id, ...also];
|
|
67
|
+
const { closeQuestion } = await import("../../db/queries/questions.js");
|
|
68
|
+
const db = await openDb();
|
|
69
|
+
const results = [];
|
|
70
|
+
for (const qid of ids) {
|
|
71
|
+
const closed = closeQuestion(db, qid, parsed.answer);
|
|
72
|
+
if (!closed)
|
|
73
|
+
fail("NOT_FOUND", `question "${qid}" not found`);
|
|
74
|
+
results.push({ id: qid, kind: closed.kind, answer: closed.answer, rule_key: closed.rule_key });
|
|
75
|
+
}
|
|
76
|
+
emitList(results, ANSWERED_COLUMNS);
|
|
77
|
+
}
|
|
78
|
+
const DEFER_SPEC = z.object({
|
|
79
|
+
days: int().default(7),
|
|
80
|
+
});
|
|
81
|
+
async function deferQuestion(id, opts) {
|
|
82
|
+
const parsed = parseInput(DEFER_SPEC, opts);
|
|
83
|
+
const { deferQuestion: deferQuestionRow } = await import("../../db/queries/questions.js");
|
|
84
|
+
const db = await openDb();
|
|
85
|
+
const ok = deferQuestionRow(db, id, parsed.days);
|
|
86
|
+
if (!ok)
|
|
87
|
+
fail("NOT_FOUND", `question "${id}" not found`);
|
|
88
|
+
emitList([{ id, days: parsed.days }], DEFER_COLUMNS);
|
|
89
|
+
}
|
|
90
|
+
export function registerQuestions(program) {
|
|
91
|
+
const questions = program
|
|
92
|
+
.command("questions")
|
|
93
|
+
.description("List, answer, and defer open questions")
|
|
94
|
+
.addHelpText("after", [
|
|
95
|
+
"",
|
|
96
|
+
"Behavior: the harness opens a question when a row is ambiguous; you list them, then answer or defer.",
|
|
97
|
+
"Typical flow: list --json, resolve each by kind, then answer (--also <id,id> closes siblings) or defer.",
|
|
98
|
+
"By kind: similar_accounts -> accounts merge; uncategorized -> transactions recategorize, then merchants set-default; unknown_merchant -> merchants upsert; currency_mismatch -> re-post as linked legs through equity:conversion:<ccy>.",
|
|
99
|
+
"Example: oled questions list --json",
|
|
100
|
+
].join("\n"));
|
|
101
|
+
questions
|
|
102
|
+
.command("list")
|
|
103
|
+
.description("List questions")
|
|
104
|
+
.option("--batch <id>", "filter by batch id")
|
|
105
|
+
.option("--include-deferred", "include deferred questions")
|
|
106
|
+
.option("--no-redact", "skip PII redaction (on by default)")
|
|
107
|
+
.action(runAction(listQuestions));
|
|
108
|
+
questions
|
|
109
|
+
.command("answer <id>")
|
|
110
|
+
.description("Answer a question")
|
|
111
|
+
.option("--answer <text>", "the answer text")
|
|
112
|
+
.option("--also <ids>", "additional question ids to answer")
|
|
113
|
+
.action(runAction(answerQuestion));
|
|
114
|
+
questions
|
|
115
|
+
.command("defer <id>")
|
|
116
|
+
.description("Defer a question")
|
|
117
|
+
.option("--days <n>", "number of days to defer")
|
|
118
|
+
.action(runAction(deferQuestion));
|
|
119
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { printKeyValues } from "../format.js";
|
|
2
|
+
import { openDb } from "../db.js";
|
|
3
|
+
import { currentMode, emit, fail, runAction } from "../output.js";
|
|
4
|
+
import { ISO_DATE_RE } from "../../lib/date.js";
|
|
5
|
+
async function showReport(opts) {
|
|
6
|
+
if (!opts.from || !opts.to)
|
|
7
|
+
fail("USAGE", "--from and --to are required");
|
|
8
|
+
if (!ISO_DATE_RE.test(opts.from)) {
|
|
9
|
+
fail("USAGE", `--from must be an ISO date (YYYY-MM-DD), got "${opts.from}"`);
|
|
10
|
+
}
|
|
11
|
+
if (!ISO_DATE_RE.test(opts.to)) {
|
|
12
|
+
fail("USAGE", `--to must be an ISO date (YYYY-MM-DD), got "${opts.to}"`);
|
|
13
|
+
}
|
|
14
|
+
const { getPeriodTotals } = await import("../../accounts/balances.js");
|
|
15
|
+
const db = await openDb();
|
|
16
|
+
const totals = getPeriodTotals(db, opts.from, opts.to);
|
|
17
|
+
const result = {
|
|
18
|
+
from: opts.from,
|
|
19
|
+
to: opts.to,
|
|
20
|
+
income: totals.income,
|
|
21
|
+
expenses: totals.expenses,
|
|
22
|
+
net: totals.income - totals.expenses,
|
|
23
|
+
};
|
|
24
|
+
const mode = currentMode();
|
|
25
|
+
if (mode.json) {
|
|
26
|
+
emit(result);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
printKeyValues(mode, [
|
|
30
|
+
["from", result.from],
|
|
31
|
+
["to", result.to],
|
|
32
|
+
["income", result.income],
|
|
33
|
+
["expenses", result.expenses],
|
|
34
|
+
["net", result.net],
|
|
35
|
+
], { bold: mode.color });
|
|
36
|
+
}
|
|
37
|
+
export function registerReport(program) {
|
|
38
|
+
program
|
|
39
|
+
.command("report")
|
|
40
|
+
.description("Income, expenses, and net")
|
|
41
|
+
.option("--from <date>", "start date")
|
|
42
|
+
.option("--to <date>", "end date")
|
|
43
|
+
.addHelpText("after", [
|
|
44
|
+
"",
|
|
45
|
+
"Behavior: sums income, expenses, and net over a date range. For net worth use oled status.",
|
|
46
|
+
"Typical flow: both dates are required and ISO (YYYY-MM-DD).",
|
|
47
|
+
"Example: oled report --from 2025-01-01 --to 2025-03-31 --json",
|
|
48
|
+
].join("\n"))
|
|
49
|
+
.action(runAction(showReport));
|
|
50
|
+
}
|