@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.
Files changed (81) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +191 -0
  3. package/datasets/defaults/cn.json +1 -0
  4. package/datasets/defaults/jp.json +1 -0
  5. package/datasets/defaults/th.json +1 -0
  6. package/datasets/defaults/us.json +1 -0
  7. package/datasets/institutions/cn.json +47 -0
  8. package/datasets/institutions/jp.json +59 -0
  9. package/datasets/institutions/th.json +99 -0
  10. package/datasets/institutions/us.json +70 -0
  11. package/dist/accounts/accounts.js +99 -0
  12. package/dist/accounts/balances.js +109 -0
  13. package/dist/accounts/matching.js +72 -0
  14. package/dist/accounts/resolve.js +118 -0
  15. package/dist/cli/commands/accounts.js +477 -0
  16. package/dist/cli/commands/config.js +175 -0
  17. package/dist/cli/commands/datasets.js +56 -0
  18. package/dist/cli/commands/doctor.js +153 -0
  19. package/dist/cli/commands/files.js +72 -0
  20. package/dist/cli/commands/ingest-commit.js +279 -0
  21. package/dist/cli/commands/ingest.js +203 -0
  22. package/dist/cli/commands/merchants.js +140 -0
  23. package/dist/cli/commands/notes.js +71 -0
  24. package/dist/cli/commands/open.js +54 -0
  25. package/dist/cli/commands/questions.js +119 -0
  26. package/dist/cli/commands/report.js +50 -0
  27. package/dist/cli/commands/setup.js +61 -0
  28. package/dist/cli/commands/status.js +187 -0
  29. package/dist/cli/commands/transactions.js +420 -0
  30. package/dist/cli/commands/vault.js +65 -0
  31. package/dist/cli/currency.js +28 -0
  32. package/dist/cli/db.js +5 -0
  33. package/dist/cli/format.js +71 -0
  34. package/dist/cli/index.js +19 -0
  35. package/dist/cli/output.js +304 -0
  36. package/dist/cli/program.js +140 -0
  37. package/dist/config.js +104 -0
  38. package/dist/context.js +30 -0
  39. package/dist/datasets/defaults.js +29 -0
  40. package/dist/datasets/index.js +33 -0
  41. package/dist/datasets/institutions.js +31 -0
  42. package/dist/datasets/loader.js +57 -0
  43. package/dist/db/connection.js +39 -0
  44. package/dist/db/encryption.js +42 -0
  45. package/dist/db/migrations/0001_baseline.js +121 -0
  46. package/dist/db/migrations/index.js +3 -0
  47. package/dist/db/queries/accounts.js +108 -0
  48. package/dist/db/queries/balances.js +61 -0
  49. package/dist/db/queries/files.js +72 -0
  50. package/dist/db/queries/merchants.js +163 -0
  51. package/dist/db/queries/notes.js +28 -0
  52. package/dist/db/queries/questions.js +117 -0
  53. package/dist/db/queries/transactions-dedup.js +80 -0
  54. package/dist/db/queries/transactions.js +319 -0
  55. package/dist/db/queries/vault.js +48 -0
  56. package/dist/db/schema.js +105 -0
  57. package/dist/extract/extract.js +89 -0
  58. package/dist/extract/ocr.js +164 -0
  59. package/dist/extract/pdf.js +127 -0
  60. package/dist/extract/presets/index.js +18 -0
  61. package/dist/extract/presets/lighton-ocr.js +12 -0
  62. package/dist/extract/presets/typhoon-ocr.js +23 -0
  63. package/dist/extract/route.js +40 -0
  64. package/dist/extract/source.js +84 -0
  65. package/dist/ingest/commit.js +313 -0
  66. package/dist/ingest/dedup.js +35 -0
  67. package/dist/ingest/prepare.js +241 -0
  68. package/dist/ingest/vault.js +56 -0
  69. package/dist/lib/date.js +6 -0
  70. package/dist/lib/ids.js +25 -0
  71. package/dist/lib/json.js +12 -0
  72. package/dist/lib/masked.js +49 -0
  73. package/dist/lib/money.js +35 -0
  74. package/dist/lib/patch.js +29 -0
  75. package/dist/lib/result.js +15 -0
  76. package/dist/lib/validate.js +154 -0
  77. package/dist/privacy/redactor.js +140 -0
  78. package/dist/setup/hosts.js +32 -0
  79. package/dist/setup/install.js +60 -0
  80. package/package.json +60 -0
  81. package/skills/SKILL.md +18 -0
@@ -0,0 +1,61 @@
1
+ import { currentMode, emit, fail, runAction } from "../output.js";
2
+ import { installSkill, skillMd, SkillPackVersionError, } from "../../setup/install.js";
3
+ import { SKILL_HOSTS, findHost, DEFAULT_HOST } from "../../setup/hosts.js";
4
+ function hostIds() {
5
+ return SKILL_HOSTS.map((h) => h.id).join(", ");
6
+ }
7
+ async function setupSkill(opts) {
8
+ // --print dumps SKILL.md as raw markdown (not NDJSON) even when --json is set.
9
+ if (opts.print) {
10
+ const md = skillMd();
11
+ process.stdout.write(md);
12
+ if (!md.endsWith("\n"))
13
+ process.stdout.write("\n");
14
+ return;
15
+ }
16
+ const host = opts.host ?? DEFAULT_HOST;
17
+ if (!findHost(host)) {
18
+ fail("USAGE", `unknown --host ${host}`, { hint: `known hosts: ${hostIds()}` });
19
+ }
20
+ const installOpts = {
21
+ host,
22
+ global: opts.global,
23
+ dir: opts.dir,
24
+ force: opts.force,
25
+ };
26
+ let target;
27
+ try {
28
+ target = installSkill(installOpts);
29
+ }
30
+ catch (err) {
31
+ if (err instanceof SkillPackVersionError) {
32
+ fail("INVALID", err.message, {
33
+ hint: "re-run with --force to overwrite the installed skill pack",
34
+ details: {
35
+ installed_version: err.installedVersion,
36
+ cli_version: err.cliVersion,
37
+ path: err.path,
38
+ },
39
+ });
40
+ }
41
+ throw err;
42
+ }
43
+ const mode = currentMode();
44
+ if (mode.json) {
45
+ emit({ installed: [target] });
46
+ }
47
+ else {
48
+ process.stdout.write(`${target.kind}\t${target.path}\t${target.version}\n`);
49
+ }
50
+ }
51
+ export function registerSetup(program) {
52
+ program
53
+ .command("setup")
54
+ .description("Install the skill for an agent CLI")
55
+ .option("--host <id>", `target agent host: ${hostIds()}`, DEFAULT_HOST)
56
+ .option("--global", "install under the host's home skills dir instead of the cwd")
57
+ .option("--dir <path>", "override the install base directory")
58
+ .option("--force", "overwrite an installed skill dir whose version differs")
59
+ .option("--print", "print SKILL.md to stdout as raw markdown and exit (ignores --json)")
60
+ .action(runAction(setupSkill));
61
+ }
@@ -0,0 +1,187 @@
1
+ import chalk from "chalk";
2
+ import { config, getConfigPath, getDataDir, keyFingerprint } from "../../config.js";
3
+ import { existsSync } from "fs";
4
+ import { formatAmount } from "../currency.js";
5
+ import { banner, visibleLength, ANSI_RE, formatInt } from "../format.js";
6
+ import { currentMode, emit, runAction } from "../output.js";
7
+ import { tryExecute } from "../../lib/result.js";
8
+ import { openDb } from "../db.js";
9
+ async function buildReport() {
10
+ const report = {
11
+ type: "status",
12
+ configured: existsSync(getConfigPath()) || existsSync(config.dbPath),
13
+ config_path: getConfigPath(),
14
+ data_dir: getDataDir(),
15
+ locale: config.displayLocale,
16
+ currency: config.displayCurrency,
17
+ user_name: config.userName,
18
+ db: {
19
+ path: config.dbPath,
20
+ reachable: false,
21
+ encrypted: !!config.dbEncryptionKey,
22
+ key_fingerprint: config.dbEncryptionKey ? keyFingerprint(config.dbEncryptionKey) : null,
23
+ error: null,
24
+ },
25
+ counts: null,
26
+ files: null,
27
+ questions: null,
28
+ net_worth: null,
29
+ };
30
+ // Deferred so non-db commands skip the libsql cost at startup.
31
+ const { getNetWorth } = await import("../../accounts/balances.js");
32
+ const { countAccounts } = await import("../../db/queries/accounts.js");
33
+ const { countTransactions } = await import("../../db/queries/transactions.js");
34
+ const { countFiles } = await import("../../db/queries/files.js");
35
+ const { countQuestions } = await import("../../db/queries/questions.js");
36
+ const { countMerchants } = await import("../../db/queries/merchants.js");
37
+ const { countNotes } = await import("../../db/queries/notes.js");
38
+ // Opening the db is the only reachability check; a failing count query must not read as not-ready.
39
+ const opened = await tryExecute(() => openDb());
40
+ if (!opened.ok) {
41
+ report.db.error = opened.error;
42
+ return report;
43
+ }
44
+ const db = opened.value;
45
+ report.db.reachable = true;
46
+ report.counts = {
47
+ accounts: countAccounts(db),
48
+ transactions: countTransactions(db),
49
+ merchants: countMerchants(db),
50
+ notes: countNotes(db),
51
+ };
52
+ report.files = countFiles(db);
53
+ const open = countQuestions(db);
54
+ const total = countQuestions(db, { includeDeferred: true });
55
+ report.questions = { open, deferred: Math.max(0, total - open) };
56
+ report.net_worth = getNetWorth(db);
57
+ return report;
58
+ }
59
+ // Fields that can leak the username or home dir ("error" can embed the db path).
60
+ const STATUS_REDACT_FIELDS = ["config_path", "data_dir", "path", "error", "user_name"];
61
+ /** Redaction is on unless `--no-redact` explicitly turned it off, so bare `oled`
62
+ * (which has no such flag) masks the same fields `oled status` does. */
63
+ export async function showStatus(opts = {}) {
64
+ let report = await buildReport();
65
+ if (opts.redact !== false) {
66
+ const { applyRedaction } = await import("../../privacy/redactor.js");
67
+ report = applyRedaction(report, true, STATUS_REDACT_FIELDS);
68
+ }
69
+ const mode = currentMode();
70
+ if (mode.json) {
71
+ emit(report);
72
+ return;
73
+ }
74
+ if (mode.tty) {
75
+ renderTty(report, mode.color);
76
+ return;
77
+ }
78
+ renderPlain(report);
79
+ }
80
+ function renderPlain(r) {
81
+ const lines = [
82
+ ["configured", r.configured],
83
+ ["config_path", r.config_path],
84
+ ["data_dir", r.data_dir],
85
+ ["locale", r.locale],
86
+ ["currency", r.currency],
87
+ ["user_name", r.user_name],
88
+ ["db_path", r.db.path],
89
+ ["db_reachable", r.db.reachable],
90
+ ["db_encrypted", r.db.encrypted],
91
+ ["db_key_fingerprint", r.db.key_fingerprint ?? "not set"],
92
+ ];
93
+ if (r.db.error)
94
+ lines.push(["db_error", r.db.error]);
95
+ if (r.counts) {
96
+ lines.push(["accounts", r.counts.accounts], ["transactions", r.counts.transactions], ["merchants", r.counts.merchants], ["notes", r.counts.notes]);
97
+ }
98
+ if (r.files) {
99
+ lines.push(["files_ingested", r.files.ingested], ["files_pending", r.files.pending], ["files_failed", r.files.failed]);
100
+ }
101
+ if (r.questions) {
102
+ lines.push(["questions_open", r.questions.open], ["questions_deferred", r.questions.deferred]);
103
+ }
104
+ if (r.net_worth) {
105
+ lines.push(["net_worth", r.net_worth.net_worth], ["assets", r.net_worth.assets], ["liabilities", r.net_worth.liabilities]);
106
+ }
107
+ process.stdout.write(lines.map(([k, v]) => `${k}\t${v}`).join("\n") + "\n");
108
+ }
109
+ const LABEL_WIDTH = 18;
110
+ function renderTty(r, color) {
111
+ const dim = (s) => (color ? chalk.dim(s) : s);
112
+ const bold = (s) => (color ? chalk.bold.yellow(s) : s);
113
+ const section = (title, rows) => {
114
+ process.stdout.write(bold(title) + "\n");
115
+ const valueWidth = Math.max(0, ...rows.map(([, v]) => visibleLength(v)));
116
+ for (const [label, value] of rows) {
117
+ const pad = " ".repeat(Math.max(0, valueWidth - visibleLength(value)));
118
+ process.stdout.write(` ${label.padEnd(LABEL_WIDTH)}${pad}${value}\n`);
119
+ }
120
+ process.stdout.write("\n");
121
+ };
122
+ process.stdout.write("\n" + (color ? banner() : stripBanner()) + "\n\n");
123
+ section("System", [
124
+ ["Configured", r.configured ? "yes" : dim("no")],
125
+ ["User", dim(r.user_name)],
126
+ ["Locale", dim(r.locale)],
127
+ ["Currency", dim(r.currency)],
128
+ ["Data dir", dim(r.data_dir)],
129
+ [
130
+ "Database",
131
+ r.db.reachable
132
+ ? `ready${r.db.encrypted ? dim(" (encrypted)") : ""}`
133
+ : dim(r.db.error ? `not ready — ${r.db.error}` : "not ready"),
134
+ ],
135
+ ["Key", r.db.key_fingerprint ? dim(r.db.key_fingerprint) : dim("not set")],
136
+ ]);
137
+ // status always exits 0, so an unreachable db needs a pointer to the surface
138
+ // that diagnoses it.
139
+ if (!r.db.reachable)
140
+ process.stdout.write(dim(" run `oled doctor` for details") + "\n\n");
141
+ if (r.counts) {
142
+ section("Ledger", [
143
+ ["Accounts", formatInt(r.counts.accounts)],
144
+ ["Transactions", formatInt(r.counts.transactions)],
145
+ ["Merchants", formatInt(r.counts.merchants)],
146
+ ["Notes", formatInt(r.counts.notes)],
147
+ ]);
148
+ }
149
+ if (r.files || r.questions) {
150
+ const rows = [];
151
+ if (r.files) {
152
+ const extras = [];
153
+ if (r.files.pending > 0)
154
+ extras.push(`${r.files.pending} pending`);
155
+ if (r.files.failed > 0)
156
+ extras.push(`${r.files.failed} failed`);
157
+ rows.push([
158
+ "Files",
159
+ `${formatInt(r.files.ingested)}${extras.length ? " " + dim(`(${extras.join(", ")})`) : ""}`,
160
+ ]);
161
+ }
162
+ if (r.questions) {
163
+ rows.push([
164
+ "Questions",
165
+ `${formatInt(r.questions.open)} open${r.questions.deferred ? " " + dim(`(${r.questions.deferred} deferred)`) : ""}`,
166
+ ]);
167
+ }
168
+ section("Pipeline", rows);
169
+ }
170
+ if (r.net_worth) {
171
+ section("Financial", [
172
+ ["Net worth", formatAmount(r.net_worth.net_worth)],
173
+ ["Assets", dim(formatAmount(r.net_worth.assets))],
174
+ ["Liabilities", dim(formatAmount(r.net_worth.liabilities))],
175
+ ]);
176
+ }
177
+ }
178
+ function stripBanner() {
179
+ return banner().replace(ANSI_RE, "");
180
+ }
181
+ export function registerStatus(program) {
182
+ program
183
+ .command("status")
184
+ .description("Status: config, database, ledger counts, net worth")
185
+ .option("--no-redact", "skip PII redaction (on by default)")
186
+ .action(runAction(showStatus));
187
+ }
@@ -0,0 +1,420 @@
1
+ import { currentMode, emit, emitList, emitObject, emitSummary, fail, mapNotFoundError, readStdinToEnd, requireYes, runAction, } from "../output.js";
2
+ import { openDb } from "../db.js";
3
+ import { insertTransaction, deleteTransaction as deleteTransactionRow, updateTransactionMeta, bulkRecategorize, listTransactions as queryTransactions, countTransactions, clampListLimit, findTransactionById, voidTransactionAsMirror, } from "../../db/queries/transactions.js";
4
+ import { findDuplicateTransactions, } from "../../db/queries/transactions-dedup.js";
5
+ import { findAccountById } from "../../db/queries/accounts.js";
6
+ import { commitTransaction, defaultTransactionCommitHooks, CURRENCY_MISMATCH_HINT, } from "../../ingest/commit.js";
7
+ import { autoMergeStrictDuplicateTransactions } from "../../ingest/dedup.js";
8
+ import { fromMinorUnits, toMinorUnits } from "../../lib/money.js";
9
+ import { getDisplayCurrency } from "../currency.js";
10
+ import { newBatchId } from "../../lib/ids.js";
11
+ import { applyRedaction } from "../../privacy/redactor.js";
12
+ import { todayIso } from "../../lib/date.js";
13
+ import * as z from "zod";
14
+ import { parseInput, str, num, json } from "../../lib/validate.js";
15
+ // Amounts are minor units in the DB; this module converts to/from decimals at the CLI boundary.
16
+ // Free-text fields on a transaction that may carry PII. Ids, amount, currency, and
17
+ // dates are structured data the agent needs verbatim and are left intact.
18
+ const TRANSACTION_REDACT_FIELDS = [
19
+ "description",
20
+ "raw_descriptor",
21
+ "merchant_name",
22
+ "debit_account_name",
23
+ "credit_account_name",
24
+ ];
25
+ function presentTransaction(row) {
26
+ return { ...row, amount: fromMinorUnits(row.amount, row.currency) };
27
+ }
28
+ const LIST_COLUMNS = [
29
+ { header: "ID", value: (t) => t.id },
30
+ { header: "Date", value: (t) => t.date },
31
+ { header: "Description", value: (t) => t.description },
32
+ { header: "Debit", value: (t) => t.debit_account_name ?? t.debit_account_id },
33
+ { header: "Credit", value: (t) => t.credit_account_name ?? t.credit_account_id },
34
+ { header: "Amount", value: (t) => t.amount.toFixed(2), align: "right" },
35
+ { header: "Currency", value: (t) => t.currency },
36
+ ];
37
+ const LIST_TRANSACTIONS_SPEC = z.object({
38
+ account: str().optional(),
39
+ from: str().optional(),
40
+ to: str().optional(),
41
+ query: str().optional(),
42
+ amount: num().optional(),
43
+ currency: str().optional(),
44
+ limit: num().optional(),
45
+ });
46
+ async function listTransactions(opts) {
47
+ const db = await openDb();
48
+ const parsed = parseInput(LIST_TRANSACTIONS_SPEC, opts);
49
+ const listOpts = {};
50
+ if (parsed.account)
51
+ listOpts.account = parsed.account;
52
+ if (parsed.from)
53
+ listOpts.from = parsed.from;
54
+ if (parsed.to)
55
+ listOpts.to = parsed.to;
56
+ if (parsed.query)
57
+ listOpts.query = parsed.query;
58
+ // Amount crosses the decimal -> minor-unit boundary here; currency defaults
59
+ // to the configured display currency when the caller doesn't pin one.
60
+ if (parsed.amount !== undefined) {
61
+ listOpts.amount = toMinorUnits(parsed.amount, parsed.currency ?? getDisplayCurrency());
62
+ }
63
+ if (parsed.limit !== undefined)
64
+ listOpts.limit = parsed.limit;
65
+ const total = countTransactions(db, listOpts);
66
+ const limit = clampListLimit(listOpts.limit);
67
+ if (opts.group) {
68
+ const clusters = queryTransactions(db, { ...listOpts, group: true });
69
+ emitClusters(clusters, !!opts.redact);
70
+ const returned = clusters.reduce((n, c) => n + c.transactions.length, 0);
71
+ emitSummary({ total, returned, has_more: total > returned, limit });
72
+ return;
73
+ }
74
+ const rows = applyRedaction(queryTransactions(db, listOpts).map(presentTransaction), !!opts.redact, TRANSACTION_REDACT_FIELDS);
75
+ emitList(rows, LIST_COLUMNS);
76
+ emitSummary({ total, returned: rows.length, has_more: total > rows.length, limit });
77
+ }
78
+ function emitClusters(clusters, redact) {
79
+ const view = clusters.map((c) => ({
80
+ group_id: c.group_id,
81
+ transactions: applyRedaction(c.transactions.map(presentTransaction), redact, TRANSACTION_REDACT_FIELDS),
82
+ }));
83
+ const mode = currentMode();
84
+ if (mode.json) {
85
+ for (const c of view)
86
+ emit(c);
87
+ return;
88
+ }
89
+ for (const c of view) {
90
+ process.stdout.write(`${c.group_id ?? "(ungrouped)"}\n`);
91
+ for (const t of c.transactions) {
92
+ process.stdout.write(` ${t.id} ${t.date} ${t.description} ${t.debit_account_name ?? t.debit_account_id} -> ${t.credit_account_name ?? t.credit_account_id} ${t.amount.toFixed(2)} ${t.currency}\n`);
93
+ }
94
+ }
95
+ }
96
+ async function showTransaction(id, opts) {
97
+ const db = await openDb();
98
+ const detail = findTransactionById(db, id);
99
+ if (!detail)
100
+ fail("NOT_FOUND", `transaction "${id}" not found`);
101
+ const view = presentTransaction(detail);
102
+ if (detail.group)
103
+ view.group = detail.group.map(presentTransaction);
104
+ emitObject(applyRedaction(view, !!opts.redact, TRANSACTION_REDACT_FIELDS));
105
+ }
106
+ const DUPLICATE_COLUMNS = [
107
+ { header: "Group", value: (r) => String(r.group), align: "right" },
108
+ { header: "ID", value: (r) => r.id },
109
+ { header: "Date", value: (r) => r.date },
110
+ { header: "Amount", value: (r) => r.amount.toFixed(2), align: "right" },
111
+ { header: "Currency", value: (r) => r.currency },
112
+ { header: "Description", value: (r) => r.description },
113
+ {
114
+ header: "Accounts",
115
+ value: (r) => `${r.debit_account_name ?? r.debit_account_id} -> ${r.credit_account_name ?? r.credit_account_id}`,
116
+ },
117
+ { header: "Source File ID", value: (r) => r.source_file_id ?? "" },
118
+ { header: "Merchant ID", value: (r) => r.merchant_id ?? "" },
119
+ ];
120
+ async function dedupeTransactions(opts) {
121
+ const db = await openDb();
122
+ let autoMerged;
123
+ if (opts.autoMerge) {
124
+ autoMerged = autoMergeStrictDuplicateTransactions(db).merged;
125
+ }
126
+ const groups = findDuplicateTransactions(db);
127
+ const rows = applyRedaction(groups.flatMap((group, i) => group.map((t) => ({ ...t, amount: fromMinorUnits(t.amount, t.currency), group: i }))), !!opts.redact, TRANSACTION_REDACT_FIELDS);
128
+ emitList(rows, DUPLICATE_COLUMNS);
129
+ emitSummary({
130
+ groups: groups.length,
131
+ ...(autoMerged !== undefined ? { auto_merged: autoMerged } : {}),
132
+ });
133
+ }
134
+ const MERGE_TRANSACTIONS_SPEC = z.object({
135
+ from: str(),
136
+ to: str(),
137
+ });
138
+ async function mergeTransactions(opts) {
139
+ const parsed = parseInput(MERGE_TRANSACTIONS_SPEC, opts);
140
+ requireYes(opts, "merging transactions");
141
+ const db = await openDb();
142
+ let result;
143
+ try {
144
+ result = voidTransactionAsMirror(db, parsed.from, parsed.to);
145
+ }
146
+ catch (err) {
147
+ mapNotFoundError(err);
148
+ }
149
+ if (result.alreadyVoid) {
150
+ emitObject({ from: parsed.from, to: parsed.to, voided: false, already_void: true });
151
+ return;
152
+ }
153
+ emitObject({ from: parsed.from, to: parsed.to, voided: true });
154
+ }
155
+ const ADD_TRANSACTION_FLAGS_SPEC = z.object({
156
+ debit_account_id: str(),
157
+ credit_account_id: str(),
158
+ amount: num(),
159
+ date: str().optional(),
160
+ description: str().optional(),
161
+ });
162
+ const ADD_TRANSACTION_FLAGS_OPTS = {
163
+ labels: { debit_account_id: "--debit-account", credit_account_id: "--credit-account" },
164
+ aliases: { debit_account_id: ["debitAccount"], credit_account_id: ["creditAccount"] },
165
+ };
166
+ // Deliberately loose (debit/credit default to "", amount passes through
167
+ // unchecked) — addTransaction's strict/resolve checks own exit codes and messages.
168
+ const ADD_TRANSACTION_STDIN_SPEC = z.object({
169
+ date: str().default(""),
170
+ description: str().optional(),
171
+ debit_account_id: str().default(""),
172
+ credit_account_id: str().default(""),
173
+ currency: str().nullable().default(null),
174
+ merchant: json().nullable().default(null),
175
+ merchant_id: str().nullable().default(null),
176
+ raw_descriptor: str().nullable().default(null),
177
+ source_page: num().nullable().default(null),
178
+ code: str().nullable().default(null),
179
+ });
180
+ const ADD_TRANSACTION_STDIN_ALIASES = {
181
+ debit_account_id: ["debit_account"],
182
+ credit_account_id: ["credit_account"],
183
+ };
184
+ // Decimal amount, no account validation — that's the caller's job.
185
+ async function buildRawTransaction(opts) {
186
+ const anyFlag = opts.debitAccount !== undefined || opts.creditAccount !== undefined || opts.amount !== undefined;
187
+ if (anyFlag) {
188
+ const parsed = parseInput(ADD_TRANSACTION_FLAGS_SPEC, opts, ADD_TRANSACTION_FLAGS_OPTS);
189
+ const raw = {
190
+ date: parsed.date ?? todayIso(),
191
+ description: parsed.description ?? opts.merchantName ?? "Manual entry",
192
+ debit_account_id: parsed.debit_account_id,
193
+ credit_account_id: parsed.credit_account_id,
194
+ amount: parsed.amount,
195
+ currency: null,
196
+ };
197
+ if (opts.merchantName)
198
+ raw.merchant = { canonical_name: opts.merchantName };
199
+ return raw;
200
+ }
201
+ const stdin = await readStdinToEnd();
202
+ if (!stdin.trim()) {
203
+ fail("USAGE", "provide --debit-account/--credit-account/--amount, or pipe a transaction JSON object on stdin");
204
+ }
205
+ let decoded;
206
+ try {
207
+ decoded = JSON.parse(stdin);
208
+ }
209
+ catch (err) {
210
+ fail("USAGE", `invalid JSON on stdin: ${err.message}`);
211
+ }
212
+ if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) {
213
+ fail("USAGE", "stdin must contain a single JSON transaction object (not an array)");
214
+ }
215
+ const record = decoded;
216
+ const parsed = parseInput(ADD_TRANSACTION_STDIN_SPEC, record, {
217
+ aliases: ADD_TRANSACTION_STDIN_ALIASES,
218
+ });
219
+ return {
220
+ ...parsed,
221
+ description: parsed.description ?? parsed.merchant?.canonical_name ?? "Manual entry",
222
+ // Un-coerced by design: record.amount, not parsed.amount — the validators below own the number check.
223
+ amount: record.amount,
224
+ };
225
+ }
226
+ function addViaResolve(db, raw) {
227
+ const batchId = newBatchId();
228
+ const ctx = {
229
+ batchId,
230
+ fileId: null,
231
+ fileHash: null,
232
+ };
233
+ const outcome = commitTransaction(db, ctx, raw, defaultTransactionCommitHooks(db, ctx));
234
+ if (!outcome.ok) {
235
+ if (outcome.reason === "currency_mismatch") {
236
+ fail("INVALID", outcome.message, { hint: CURRENCY_MISMATCH_HINT });
237
+ }
238
+ fail("INVALID", outcome.message);
239
+ }
240
+ emitObject({
241
+ transaction_id: outcome.transactionId,
242
+ duplicate: outcome.duplicate,
243
+ raised_questions: outcome.raisedQuestions,
244
+ currency_overridden: outcome.currencyOverridden,
245
+ });
246
+ }
247
+ function addStrict(db, raw) {
248
+ if (!raw.debit_account_id || !raw.credit_account_id) {
249
+ fail("USAGE", "debit_account_id and credit_account_id are required");
250
+ }
251
+ if (typeof raw.amount !== "number" || !Number.isFinite(raw.amount)) {
252
+ fail("USAGE", "amount must be a number");
253
+ }
254
+ const accountHint = "create it with `oled accounts create`, or find a close match with `oled accounts match --query <name>`, or re-run with --resolve";
255
+ const debit = findAccountById(db, raw.debit_account_id);
256
+ if (!debit)
257
+ fail("NOT_FOUND", `account "${raw.debit_account_id}" not found`, { hint: accountHint });
258
+ const credit = findAccountById(db, raw.credit_account_id);
259
+ if (!credit)
260
+ fail("NOT_FOUND", `account "${raw.credit_account_id}" not found`, { hint: accountHint });
261
+ /**
262
+ * Ledger-design §5 currency rule, applied inline: this strict path requires
263
+ * both accounts to pre-exist and raises no questions, so it can't delegate to
264
+ * commitTransaction's currency_mismatch path — only CURRENCY_MISMATCH_HINT is shared.
265
+ */
266
+ const currency = debit.currency || getDisplayCurrency();
267
+ const creditCurrency = credit.currency || getDisplayCurrency();
268
+ if (creditCurrency !== currency) {
269
+ fail("INVALID", `debit ${debit.id} is ${currency}, credit ${credit.id} is ${creditCurrency}; a single transaction can't cross currencies`, { hint: CURRENCY_MISMATCH_HINT });
270
+ }
271
+ let result;
272
+ try {
273
+ result = insertTransaction(db, {
274
+ date: raw.date,
275
+ description: raw.description,
276
+ debit_account_id: raw.debit_account_id,
277
+ credit_account_id: raw.credit_account_id,
278
+ amount: toMinorUnits(raw.amount, currency),
279
+ currency,
280
+ merchant: raw.merchant ?? null,
281
+ merchant_id: raw.merchant_id ?? null,
282
+ raw_descriptor: raw.raw_descriptor ?? null,
283
+ source_page: raw.source_page ?? null,
284
+ code: raw.code ?? null,
285
+ });
286
+ }
287
+ catch (err) {
288
+ fail("INVALID", err.message);
289
+ }
290
+ emitObject({ transaction_id: result.id, duplicate: result.duplicate });
291
+ }
292
+ async function addTransaction(opts) {
293
+ const db = await openDb();
294
+ const raw = await buildRawTransaction(opts);
295
+ if (opts.resolve)
296
+ return addViaResolve(db, raw);
297
+ return addStrict(db, raw);
298
+ }
299
+ const UPDATE_TRANSACTION_SPEC = z.object({
300
+ date: str().optional(),
301
+ description: str().optional(),
302
+ merchant_id: str().optional(),
303
+ });
304
+ const UPDATE_TRANSACTION_ALIASES = { merchant_id: ["merchant"] };
305
+ async function updateTransaction(id, opts) {
306
+ const fields = parseInput(UPDATE_TRANSACTION_SPEC, opts, {
307
+ aliases: UPDATE_TRANSACTION_ALIASES,
308
+ atLeastOne: "at least one of --date, --description, --merchant is required",
309
+ });
310
+ const db = await openDb();
311
+ const changes = updateTransactionMeta(db, id, fields);
312
+ if (changes === 0)
313
+ fail("NOT_FOUND", `transaction "${id}" not found`);
314
+ emitObject({ transaction_id: id, updated: true });
315
+ }
316
+ async function deleteTransaction(id, opts) {
317
+ requireYes(opts, "deleting this transaction");
318
+ const db = await openDb();
319
+ if (!deleteTransactionRow(db, id))
320
+ fail("NOT_FOUND", `transaction "${id}" not found`);
321
+ emitObject({ transaction_id: id, deleted: true });
322
+ }
323
+ const RECATEGORIZE_SPEC = z.object({
324
+ set_account: str(),
325
+ filter_account: str(),
326
+ });
327
+ // The `--filter-account` clarification is folded into the label so a missing
328
+ // flag still explains what that account controls.
329
+ const RECATEGORIZE_LABELS = {
330
+ filter_account: "--filter-account (recategorize moves that account's transactions)",
331
+ };
332
+ async function recategorizeTransactions(opts) {
333
+ const parsed = parseInput(RECATEGORIZE_SPEC, opts, { labels: RECATEGORIZE_LABELS });
334
+ const db = await openDb();
335
+ const filter = { accountId: parsed.filter_account };
336
+ let result;
337
+ try {
338
+ result = bulkRecategorize(db, filter, { accountId: parsed.set_account });
339
+ }
340
+ catch (err) {
341
+ fail("INVALID", err.message);
342
+ }
343
+ emitObject({
344
+ affected: result.affected,
345
+ skipped_self_transaction: result.skipped_self_transaction,
346
+ sample_transaction_ids: result.sample_transaction_ids,
347
+ });
348
+ }
349
+ export function registerTransactions(program) {
350
+ const transactions = program
351
+ .command("transactions")
352
+ .description("Transactions: list / show / add / update / delete / recategorize / dedupe / merge")
353
+ .addHelpText("after", [
354
+ "",
355
+ "Behavior: reads the ledger (list, show) and edits it (add, update, delete, recategorize, dedupe, merge).",
356
+ "Typical flow: list to find a tx:id, then show, recategorize, or delete it. Statement rows go through ingest commit, not add.",
357
+ "Direction, not sign: debit the account that grows, amount always positive. Card purchase: debit expense:<cat>, credit liability:credit_card:<x>. Bank spend: debit expense:<cat>, credit asset:bank:<x>. Salary: debit asset:bank:<x>, credit income:salary. A refund reverses the purchase's accounts; a card payment debits the liability and credits the bank; opening balances post against equity:opening-balance.",
358
+ "Example: oled transactions list --account expense:food --json",
359
+ ].join("\n"));
360
+ transactions
361
+ .command("list")
362
+ .description("List transactions with optional filters")
363
+ .option("--account <id>", "filter by account id (matches either side)")
364
+ .option("--from <date>", "filter from date")
365
+ .option("--to <date>", "filter to date")
366
+ .option("--query <text>", "filter by search text")
367
+ .option("--amount <decimal>", "filter by exact amount (decimal)")
368
+ .option("--currency <code>", "currency for --amount (defaults to the configured display currency)")
369
+ .option("--limit <n>", "max rows (default 50, max 500)")
370
+ .option("--group", "fold linked transactions into their group clusters")
371
+ .option("--no-redact", "skip PII redaction (on by default)")
372
+ .action(runAction(listTransactions));
373
+ transactions
374
+ .command("show <id>")
375
+ .description("Show a transaction's details (with its linked group when present)")
376
+ .option("--no-redact", "skip PII redaction (on by default)")
377
+ .action(runAction(showTransaction));
378
+ transactions
379
+ .command("add")
380
+ .description("Add a manual transaction; statement rows belong in `ingest commit`")
381
+ .option("--resolve", "fuzzy-resolve account/merchant hints and raise questions instead of failing")
382
+ .option("--debit-account <id>", "debit account id")
383
+ .option("--credit-account <id>", "credit account id")
384
+ .option("--amount <n>", "transaction amount (decimal)")
385
+ .option("--date <date>", "transaction date (defaults to today)")
386
+ .option("--description <text>", "transaction description")
387
+ .option("--merchant-name <name>", "merchant name to upsert and link")
388
+ .action(runAction(addTransaction));
389
+ transactions
390
+ .command("update <id>")
391
+ .description("Update a transaction's metadata")
392
+ .option("--date <date>", "transaction date")
393
+ .option("--description <text>", "transaction description")
394
+ .option("--merchant <id>", "merchant id to set")
395
+ .action(runAction(updateTransaction));
396
+ transactions
397
+ .command("delete <id>")
398
+ .description("Delete a transaction")
399
+ .option("--yes", "skip confirmation")
400
+ .action(runAction(deleteTransaction));
401
+ transactions
402
+ .command("recategorize")
403
+ .description("Bulk re-point one account's transactions onto another")
404
+ .option("--set-account <id>", "account id to move matching transactions to")
405
+ .option("--filter-account <id>", "account whose transactions are moved (required)")
406
+ .action(runAction(recategorizeTransactions));
407
+ transactions
408
+ .command("dedupe")
409
+ .description("Find likely duplicate transactions (optionally auto-merge them)")
410
+ .option("--auto-merge", "automatically merge detected duplicates")
411
+ .option("--no-redact", "skip PII redaction (on by default)")
412
+ .action(runAction(dedupeTransactions));
413
+ transactions
414
+ .command("merge")
415
+ .description("Merge a mirror transaction into its surviving twin (voids --from)")
416
+ .option("--from <id>", "mirror transaction id to void")
417
+ .option("--to <id>", "surviving transaction id")
418
+ .option("--yes", "skip confirmation")
419
+ .action(runAction(mergeTransactions));
420
+ }