@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,477 @@
1
+ import { EXIT, asRecord, currentMode, emit, emitList, emitObject, emitSummary, fail, mapNotFoundError, readStdinBatch, requireYes, runAction, } from "../output.js";
2
+ import { openDb } from "../db.js";
3
+ import { errorMessage } from "../../lib/result.js";
4
+ import { createAccount as createAccountRow, mergeAccounts as mergeAccountRows, deleteAccount as deleteAccountRow, } from "../../accounts/accounts.js";
5
+ import { getAccountBalances, getRollupBalance, adjustAccountBalance, } from "../../accounts/balances.js";
6
+ import { findAccountById, renameAccount, updateAccountMetadata, TOP_LEVEL_TYPES, } from "../../db/queries/accounts.js";
7
+ import { findAccountsByFuzzyName } from "../../accounts/matching.js";
8
+ import { ensureAccountAncestors } from "../../accounts/resolve.js";
9
+ import { fromMinorUnits } from "../../lib/money.js";
10
+ import { applyRedaction } from "../../privacy/redactor.js";
11
+ import * as z from "zod";
12
+ import { parseInput, safeParse, str, num, int, json } from "../../lib/validate.js";
13
+ // Only `name` is free text; id/parent_id/type/currency and balances are structured, left verbatim.
14
+ const ACCOUNT_REDACT_FIELDS = ["name"];
15
+ function presentAccount(a) {
16
+ const { balance_minor: _bm, debits_posted, credits_posted, ...rest } = a;
17
+ return {
18
+ ...rest,
19
+ debits_posted: fromMinorUnits(debits_posted, a.currency),
20
+ credits_posted: fromMinorUnits(credits_posted, a.currency),
21
+ };
22
+ }
23
+ const ACCOUNT_COLUMNS = [
24
+ { header: "ID", value: (a) => a.id },
25
+ { header: "Name", value: (a) => a.name },
26
+ { header: "Type", value: (a) => a.type },
27
+ { header: "Parent", value: (a) => a.parent_id ?? "" },
28
+ { header: "Balance", value: (a) => a.balance.toFixed(2), align: "right" },
29
+ { header: "Debits", value: (a) => a.debits_posted.toFixed(2), align: "right" },
30
+ { header: "Credits", value: (a) => a.credits_posted.toFixed(2), align: "right" },
31
+ { header: "Currency", value: (a) => a.currency },
32
+ ];
33
+ const MATCH_COLUMNS = [
34
+ { header: "ID", value: (m) => m.account.id },
35
+ { header: "Name", value: (m) => m.account.name },
36
+ { header: "Type", value: (m) => m.account.type },
37
+ { header: "Similarity", value: (m) => m.similarity.toFixed(3), align: "right" },
38
+ ];
39
+ function buildAccountTree(db, type) {
40
+ const rows = getAccountBalances(db, type ? { type } : {});
41
+ const byId = new Map(rows.map((r) => [r.id, r]));
42
+ const childrenMap = new Map();
43
+ const roots = [];
44
+ for (const r of rows) {
45
+ if (r.parent_id && byId.has(r.parent_id)) {
46
+ const arr = childrenMap.get(r.parent_id) ?? [];
47
+ arr.push(r);
48
+ childrenMap.set(r.parent_id, arr);
49
+ }
50
+ else {
51
+ roots.push(r);
52
+ }
53
+ }
54
+ // Per-currency sums convert in sorted order to match getRollupBalance, so the two can't drift.
55
+ const build = (row) => {
56
+ const sums = new Map();
57
+ const children = (childrenMap.get(row.id) ?? []).map((childRow) => {
58
+ const built = build(childRow);
59
+ for (const [currency, minor] of built.sums) {
60
+ sums.set(currency, (sums.get(currency) ?? 0) + minor);
61
+ }
62
+ return built.node;
63
+ });
64
+ sums.set(row.currency, (sums.get(row.currency) ?? 0) + row.balance_minor);
65
+ let rollup = 0;
66
+ for (const currency of [...sums.keys()].sort()) {
67
+ rollup += fromMinorUnits(sums.get(currency), currency);
68
+ }
69
+ return {
70
+ node: { id: row.id, name: row.name, type: row.type, balance: row.balance, rollup, children },
71
+ sums,
72
+ };
73
+ };
74
+ return roots.map((row) => build(row).node);
75
+ }
76
+ function renderTreeTty(nodes, depth = 0) {
77
+ for (const n of nodes) {
78
+ const indent = " ".repeat(depth);
79
+ process.stdout.write(`${indent}${n.name} (${n.id}) ${n.balance.toFixed(2)} [rollup ${n.rollup.toFixed(2)}]\n`);
80
+ renderTreeTty(n.children, depth + 1);
81
+ }
82
+ }
83
+ function flattenTree(nodes, depth, out) {
84
+ for (const n of nodes) {
85
+ out.push([String(depth), n.id, n.name, n.type, n.balance.toFixed(2), n.rollup.toFixed(2)].join("\t"));
86
+ flattenTree(n.children, depth + 1, out);
87
+ }
88
+ }
89
+ function renderTreePlain(nodes) {
90
+ const out = [];
91
+ flattenTree(nodes, 0, out);
92
+ if (out.length)
93
+ process.stdout.write(out.join("\n") + "\n");
94
+ }
95
+ // Fails an invalid --type with the same wording zod produces for `accounts create --type`.
96
+ function parseAccountTypeFilter(type) {
97
+ if (type === undefined)
98
+ return undefined;
99
+ if (!TOP_LEVEL_TYPES.includes(type)) {
100
+ fail("USAGE", `--type must be one of ${TOP_LEVEL_TYPES.join(", ")}, got "${type}"`);
101
+ }
102
+ return type;
103
+ }
104
+ async function listAccounts(opts) {
105
+ const db = await openDb();
106
+ const type = parseAccountTypeFilter(opts.type);
107
+ const rows = applyRedaction(getAccountBalances(db, type ? { type } : {}).map(presentAccount), !!opts.redact, ACCOUNT_REDACT_FIELDS);
108
+ emitList(rows, ACCOUNT_COLUMNS);
109
+ }
110
+ async function treeAccounts(opts) {
111
+ const db = await openDb();
112
+ const type = parseAccountTypeFilter(opts.type);
113
+ const roots = applyRedaction(buildAccountTree(db, type), !!opts.redact, ACCOUNT_REDACT_FIELDS);
114
+ const mode = currentMode();
115
+ if (mode.json) {
116
+ emit(roots);
117
+ return;
118
+ }
119
+ if (mode.tty) {
120
+ renderTreeTty(roots);
121
+ return;
122
+ }
123
+ renderTreePlain(roots);
124
+ }
125
+ async function showAccount(id, opts = {}) {
126
+ const db = await openDb();
127
+ const account = findAccountById(db, id);
128
+ if (!account)
129
+ fail("NOT_FOUND", `account "${id}" not found`);
130
+ const balances = getAccountBalances(db, { idOrParent: id });
131
+ const self = balances.find((b) => b.id === id);
132
+ const children = balances
133
+ .filter((b) => b.parent_id === id)
134
+ .map((b) => ({ id: b.id, name: b.name, type: b.type, balance: b.balance }));
135
+ emitObject(applyRedaction({
136
+ ...account,
137
+ balance: self?.balance ?? 0,
138
+ debits_posted: self ? fromMinorUnits(self.debits_posted, self.currency) : 0,
139
+ credits_posted: self ? fromMinorUnits(self.credits_posted, self.currency) : 0,
140
+ rollup: getRollupBalance(db, id),
141
+ children,
142
+ }, !!opts.redact, ACCOUNT_REDACT_FIELDS));
143
+ }
144
+ const CREATE_ACCOUNT_SPEC = z.object({
145
+ id: str(),
146
+ name: str(),
147
+ type: z.enum(TOP_LEVEL_TYPES),
148
+ parent_id: str().optional(),
149
+ subtype: str().optional(),
150
+ bank_name: str().optional(),
151
+ account_number_masked: str().optional(),
152
+ currency: str().optional(),
153
+ due_day: int().optional(),
154
+ statement_day: int().optional(),
155
+ metadata: json().optional(),
156
+ });
157
+ const CREATE_ACCOUNT_ALIASES = {
158
+ parent_id: ["parent"],
159
+ bank_name: ["bank"],
160
+ account_number_masked: ["masked"],
161
+ };
162
+ /** Auto-creates missing ancestors from the id's colon segments when no parent was
163
+ * given; skipped for an unrecognized type so `createAccountRow` reports a clean
164
+ * INVALID. Throws on failure, including the `ACCOUNT_EXISTS`-coded duplicate. */
165
+ function createOneAccount(db, parsed) {
166
+ let parentId = parsed.parent_id ?? null;
167
+ let createdParents = [];
168
+ if (parsed.parent_id === undefined && TOP_LEVEL_TYPES.includes(parsed.type)) {
169
+ const ancestors = ensureAccountAncestors(db, parsed.id, parsed.type);
170
+ if (ancestors.parentId !== null) {
171
+ parentId = ancestors.parentId;
172
+ createdParents = ancestors.createdParents;
173
+ }
174
+ }
175
+ const input = {
176
+ id: parsed.id,
177
+ name: parsed.name,
178
+ type: parsed.type,
179
+ parent_id: parentId,
180
+ subtype: parsed.subtype ?? null,
181
+ bank_name: parsed.bank_name ?? null,
182
+ account_number_masked: parsed.account_number_masked ?? null,
183
+ currency: parsed.currency,
184
+ due_day: parsed.due_day ?? null,
185
+ statement_day: parsed.statement_day ?? null,
186
+ metadata: parsed.metadata ?? null,
187
+ };
188
+ createAccountRow(db, input);
189
+ const result = { id: input.id, created_parents: createdParents };
190
+ // Echo the stored (post-normalization) value rather than re-deriving it.
191
+ if (parsed.account_number_masked !== undefined) {
192
+ result.account_number_masked = findAccountById(db, input.id)?.account_number_masked ?? null;
193
+ }
194
+ return result;
195
+ }
196
+ function maskedResultField(result) {
197
+ return result.account_number_masked !== undefined
198
+ ? { account_number_masked: result.account_number_masked }
199
+ : {};
200
+ }
201
+ async function createSingleAccount(opts) {
202
+ const parsed = parseInput(CREATE_ACCOUNT_SPEC, opts, { aliases: CREATE_ACCOUNT_ALIASES });
203
+ const db = await openDb();
204
+ let result;
205
+ try {
206
+ result = createOneAccount(db, parsed);
207
+ }
208
+ catch (err) {
209
+ mapNotFoundError(err, /does not exist/i);
210
+ }
211
+ emitObject({
212
+ id: result.id,
213
+ created: true,
214
+ created_parents: result.created_parents,
215
+ ...maskedResultField(result),
216
+ });
217
+ }
218
+ // json/color are global flags, not per-account options.
219
+ const NON_ACCOUNT_FLAG_KEYS = new Set(["input", "json", "color"]);
220
+ /** `ingest commit`'s batch shape: one result row per item, a summary row, exit
221
+ * PARTIAL(7) on any failure. `ACCOUNT_EXISTS` counts as an idempotent success
222
+ * (`duplicate: true`) so re-running a batch is a no-op. */
223
+ async function createAccountsBatch(inputPath) {
224
+ const items = await readStdinBatch(inputPath);
225
+ if (items.length === 0)
226
+ fail("USAGE", "no account data provided");
227
+ const db = await openDb();
228
+ const results = [];
229
+ let created = 0;
230
+ let duplicates = 0;
231
+ let failed = 0;
232
+ for (let index = 0; index < items.length; index++) {
233
+ const record = asRecord(items[index]);
234
+ if (!record) {
235
+ failed++;
236
+ results.push({ type: "result", index, ok: false, message: "each account must be a JSON object." });
237
+ continue;
238
+ }
239
+ const parsed = safeParse(CREATE_ACCOUNT_SPEC, record, { aliases: CREATE_ACCOUNT_ALIASES });
240
+ if (!parsed.ok) {
241
+ failed++;
242
+ results.push({ type: "result", index, ok: false, message: parsed.error });
243
+ continue;
244
+ }
245
+ try {
246
+ const one = createOneAccount(db, parsed.value);
247
+ created++;
248
+ results.push({
249
+ type: "result",
250
+ index,
251
+ ok: true,
252
+ id: one.id,
253
+ created: true,
254
+ created_parents: one.created_parents,
255
+ ...maskedResultField(one),
256
+ });
257
+ }
258
+ catch (err) {
259
+ if (err?.code === "ACCOUNT_EXISTS") {
260
+ duplicates++;
261
+ results.push({ type: "result", index, ok: true, id: parsed.value.id, duplicate: true });
262
+ continue;
263
+ }
264
+ failed++;
265
+ results.push({ type: "result", index, ok: false, message: errorMessage(err) });
266
+ }
267
+ }
268
+ const mode = currentMode();
269
+ if (mode.json) {
270
+ for (const r of results)
271
+ emit(r);
272
+ emitSummary({ created, duplicates, failed });
273
+ }
274
+ else {
275
+ for (const r of results)
276
+ emitObject(r);
277
+ process.stdout.write(`\n${created} created, ${duplicates} duplicate(s), ${failed} failed\n`);
278
+ }
279
+ if (failed > 0)
280
+ process.exitCode = EXIT.PARTIAL;
281
+ }
282
+ async function createAccount(opts) {
283
+ if (opts.input !== undefined) {
284
+ if (Object.keys(opts).some((k) => opts[k] !== undefined && !NON_ACCOUNT_FLAG_KEYS.has(k))) {
285
+ fail("USAGE", "--input and per-account flags are mutually exclusive");
286
+ }
287
+ await createAccountsBatch(opts.input);
288
+ return;
289
+ }
290
+ await createSingleAccount(opts);
291
+ }
292
+ const MERGE_ACCOUNTS_SPEC = z.object({
293
+ from: str(),
294
+ to: str(),
295
+ });
296
+ async function mergeAccounts(opts) {
297
+ const parsed = parseInput(MERGE_ACCOUNTS_SPEC, opts);
298
+ requireYes(opts, "merging accounts");
299
+ const db = await openDb();
300
+ let result;
301
+ try {
302
+ result = mergeAccountRows(db, parsed.from, parsed.to);
303
+ }
304
+ catch (err) {
305
+ mapNotFoundError(err, /does not exist/i);
306
+ }
307
+ emitObject({
308
+ from: parsed.from,
309
+ to: parsed.to,
310
+ moved: result.moved,
311
+ deleted_self_transactions: result.deletedSelfTransactions,
312
+ });
313
+ }
314
+ async function deleteAccount(id, opts) {
315
+ const db = await openDb();
316
+ if (!findAccountById(db, id))
317
+ fail("NOT_FOUND", `account "${id}" not found`);
318
+ requireYes(opts, "deleting this account");
319
+ try {
320
+ deleteAccountRow(db, id);
321
+ }
322
+ catch (err) {
323
+ mapNotFoundError(err, /does not exist/i);
324
+ }
325
+ emitObject({ id, deleted: true });
326
+ }
327
+ const ADJUST_ACCOUNT_SPEC = z.object({
328
+ to: num(),
329
+ reason: str(),
330
+ date: str().optional(),
331
+ });
332
+ async function adjustAccount(id, opts) {
333
+ const parsed = parseInput(ADJUST_ACCOUNT_SPEC, opts);
334
+ const db = await openDb();
335
+ let result;
336
+ try {
337
+ result = adjustAccountBalance(db, {
338
+ accountId: id,
339
+ targetAmount: parsed.to,
340
+ reason: parsed.reason,
341
+ date: parsed.date,
342
+ });
343
+ }
344
+ catch (err) {
345
+ mapNotFoundError(err, /does not exist/i);
346
+ }
347
+ emitObject({ transaction_id: result.transactionId, delta: result.delta });
348
+ }
349
+ const MATCH_ACCOUNTS_SPEC = z.object({
350
+ query: str(),
351
+ });
352
+ async function matchAccounts(opts) {
353
+ const parsed = parseInput(MATCH_ACCOUNTS_SPEC, opts);
354
+ const db = await openDb();
355
+ const matches = findAccountsByFuzzyName(db, parsed.query);
356
+ emitList(matches, MATCH_COLUMNS);
357
+ }
358
+ const UPDATE_ACCOUNT_SPEC = z.object({
359
+ name: str().optional(),
360
+ due_day: int().optional().nullable(),
361
+ statement_day: int().optional().nullable(),
362
+ points_balance: int().optional().nullable(),
363
+ account_number_masked: str().optional().nullable(),
364
+ bank_name: str().optional().nullable(),
365
+ metadata: json().optional(),
366
+ });
367
+ const UPDATE_ACCOUNT_ALIASES = {
368
+ points_balance: ["points"],
369
+ account_number_masked: ["masked"],
370
+ bank_name: ["bank"],
371
+ };
372
+ async function updateAccount(id, opts) {
373
+ const parsed = parseInput(UPDATE_ACCOUNT_SPEC, opts, {
374
+ aliases: UPDATE_ACCOUNT_ALIASES,
375
+ atLeastOne: "at least one of --name, --due-day, --statement-day, --points, --masked, --bank, --metadata is required",
376
+ });
377
+ const { name, ...patch } = parsed;
378
+ const db = await openDb();
379
+ const result = { id };
380
+ if (name !== undefined) {
381
+ const changes = renameAccount(db, id, name);
382
+ if (changes === 0)
383
+ fail("NOT_FOUND", `account "${id}" not found`);
384
+ result.name = name;
385
+ result.renamed = true;
386
+ }
387
+ if (Object.keys(patch).length > 0) {
388
+ let metaResult;
389
+ try {
390
+ metaResult = updateAccountMetadata(db, id, patch);
391
+ }
392
+ catch (err) {
393
+ mapNotFoundError(err, /does not exist/i);
394
+ }
395
+ Object.assign(result, metaResult);
396
+ }
397
+ emitObject(result);
398
+ }
399
+ export function registerAccounts(program) {
400
+ const accounts = program
401
+ .command("accounts")
402
+ .description("Manage the chart of accounts")
403
+ .addHelpText("after", [
404
+ "",
405
+ "Behavior: manages the chart of accounts, colon-paths under asset, liability, income, expense, equity.",
406
+ "Typical flow: match to reuse an existing account before create; read balances with tree or show.",
407
+ "Example: oled accounts match --query groceries --json",
408
+ ].join("\n"));
409
+ accounts
410
+ .command("list")
411
+ .description("List accounts")
412
+ .option("--type <type>", "filter by account type")
413
+ .option("--no-redact", "skip PII redaction (on by default)")
414
+ .action(runAction(listAccounts));
415
+ accounts
416
+ .command("tree")
417
+ .description("Show accounts as a tree")
418
+ .option("--type <type>", "filter by account type")
419
+ .option("--no-redact", "skip PII redaction (on by default)")
420
+ .action(runAction(treeAccounts));
421
+ accounts
422
+ .command("show <id>")
423
+ .description("Show an account's details")
424
+ .option("--no-redact", "skip PII redaction (on by default)")
425
+ .action(runAction(showAccount));
426
+ accounts
427
+ .command("create")
428
+ .description("Create a new account (single via flags, or batch via --input)")
429
+ .option("--id <id>", "account id")
430
+ .option("--name <name>", "account name")
431
+ .option("--type <type>", "account type")
432
+ .option("--parent <id>", "parent account id")
433
+ .option("--subtype <s>", "account subtype")
434
+ .option("--bank <name>", "bank name")
435
+ .option("--masked <number>", "masked account number")
436
+ .option("--currency <code>", "currency code")
437
+ .option("--due-day <n>", "payment due day")
438
+ .option("--statement-day <n>", "statement closing day")
439
+ .option("--metadata <json>", "additional metadata as JSON")
440
+ .option("--input <path>", "batch-create accounts from an NDJSON/JSON file instead of individual flags")
441
+ .action(runAction(createAccount));
442
+ accounts
443
+ .command("merge")
444
+ .description("Merge one account into another")
445
+ .option("--from <id>", "account id to merge from")
446
+ .option("--to <id>", "account id to merge into")
447
+ .option("--yes", "skip confirmation")
448
+ .action(runAction(mergeAccounts));
449
+ accounts
450
+ .command("delete <id>")
451
+ .description("Delete an account")
452
+ .option("--yes", "skip confirmation")
453
+ .action(runAction(deleteAccount));
454
+ accounts
455
+ .command("adjust <id>")
456
+ .description("Adjust an account balance")
457
+ .option("--to <amount>", "target balance amount")
458
+ .option("--reason <text>", "reason for the adjustment")
459
+ .option("--date <date>", "adjustment date")
460
+ .action(runAction(adjustAccount));
461
+ accounts
462
+ .command("match")
463
+ .description("Match accounts against a query")
464
+ .option("--query <text>", "search text")
465
+ .action(runAction(matchAccounts));
466
+ accounts
467
+ .command("update <id>")
468
+ .description("Update an account's name and/or metadata")
469
+ .option("--name <name>", "new account name")
470
+ .option("--due-day <n>", "payment due day")
471
+ .option("--statement-day <n>", "statement closing day")
472
+ .option("--points <n>", "reward points balance")
473
+ .option("--masked <number>", "masked account number")
474
+ .option("--bank <name>", "bank name")
475
+ .option("--metadata <json>", "additional metadata as JSON")
476
+ .action(runAction(updateAccount));
477
+ }
@@ -0,0 +1,175 @@
1
+ import { existsSync, mkdirSync } from "fs";
2
+ import { openDb } from "../db.js";
3
+ import { config as appConfig, CONFIG_SECRETS, getConfigPath, keyFingerprint, loadPersistedConfig, saveConfig, } from "../../config.js";
4
+ import { findCountryDefaults, availableCountries } from "../../datasets/defaults.js";
5
+ import { generateKey } from "../../db/encryption.js";
6
+ import { getContextPath } from "../../context.js";
7
+ import { printKeyValues } from "../format.js";
8
+ import { currentMode, emit, fail, readSecretFromStdin, runAction } from "../output.js";
9
+ import * as z from "zod";
10
+ import { parseInput, str, bool } from "../../lib/validate.js";
11
+ /** Every CONFIG_SECRETS key surfaces as {set, fingerprint}, never plaintext (which would land in shells/logs/bug reports). */
12
+ function redactConfig(cfg) {
13
+ const redacted = { ...cfg };
14
+ for (const key of CONFIG_SECRETS) {
15
+ const value = cfg[key];
16
+ redacted[key] = value ? { set: true, fingerprint: keyFingerprint(value) } : { set: false };
17
+ }
18
+ return redacted;
19
+ }
20
+ /** Redacted config plus the resolved context.md path (there's no separate `context` command). */
21
+ function showPayload() {
22
+ return { ...redactConfig(appConfig), context_path: getContextPath() };
23
+ }
24
+ function flattenRows(obj) {
25
+ const rows = [];
26
+ for (const [k, v] of Object.entries(obj)) {
27
+ if (v && typeof v === "object" && !Array.isArray(v)) {
28
+ for (const [nk, nv] of Object.entries(v)) {
29
+ rows.push([`${k}.${nk}`, String(nv)]);
30
+ }
31
+ }
32
+ else {
33
+ rows.push([k, String(v)]);
34
+ }
35
+ }
36
+ return rows;
37
+ }
38
+ function printConfig(mode, data) {
39
+ if (mode.json) {
40
+ emit(data);
41
+ return;
42
+ }
43
+ printKeyValues(mode, flattenRows(data));
44
+ }
45
+ /** Every flag the bare `config` action accepts; snake_case so parseInput auto-bridges commander's camelCase opts. */
46
+ const CONVERGE_FLAGS_SPEC = z.object({
47
+ data_dir: str().optional(),
48
+ db: str().optional(),
49
+ generate_key: bool().optional(),
50
+ encryption_key_stdin: bool().optional(),
51
+ locale: str().optional(),
52
+ currency: str().optional(),
53
+ user_name: str().optional(),
54
+ country: str().optional(),
55
+ ocr_url: str().optional(),
56
+ ocr_model: str().optional(),
57
+ });
58
+ /**
59
+ * Precedence: flag > env var > persisted file > dataset default > code
60
+ * default (displayLocale/currency have no env var tier). `country` defaults
61
+ * to "th" and is validated, so an unknown name never reaches the file.
62
+ */
63
+ function resolveConvergedConfig(flags) {
64
+ const country = flags.country ?? "th";
65
+ const defaults = findCountryDefaults(country);
66
+ if (!defaults) {
67
+ fail("USAGE", `unknown country "${country}"`, {
68
+ hint: `available: ${availableCountries().join(", ")}`,
69
+ });
70
+ }
71
+ const persisted = loadPersistedConfig();
72
+ return {
73
+ dataDir: flags.data_dir ?? appConfig.dataDir,
74
+ dbPath: flags.db ?? appConfig.dbPath,
75
+ displayLocale: flags.locale || persisted.displayLocale || defaults.locale,
76
+ displayCurrency: flags.currency || persisted.displayCurrency || defaults.currency,
77
+ userName: flags.user_name ?? appConfig.userName,
78
+ // The URL alone decides whether OCR is configured.
79
+ ocrBaseUrl: flags.ocr_url ?? appConfig.ocrBaseUrl,
80
+ ocrModel: flags.ocr_model ?? appConfig.ocrModel,
81
+ };
82
+ }
83
+ /**
84
+ * `undefined` means "leave the persisted key alone", which is also what
85
+ * --generate-key means once a key exists: minting a new one over a live key
86
+ * would orphan the encrypted db.
87
+ */
88
+ async function resolveEncryptionKey(flags) {
89
+ if (flags.generate_key)
90
+ return appConfig.dbEncryptionKey ? undefined : generateKey();
91
+ if (flags.encryption_key_stdin)
92
+ return readSecretFromStdin();
93
+ return undefined;
94
+ }
95
+ /**
96
+ * No re-encryption path exists, so refuse before saveConfig — a bad request
97
+ * must leave both the config file and the database untouched.
98
+ */
99
+ function guardKeyChange(key, dbPath) {
100
+ if (key === undefined || key === appConfig.dbEncryptionKey || !existsSync(dbPath))
101
+ return;
102
+ // A keyless db is the common case: any earlier command created it, so say so
103
+ // rather than talking about changing a key the caller never set.
104
+ const change = appConfig.dbEncryptionKey ? "changing its encryption key" : "encrypting it now";
105
+ fail("INVALID", `database ${dbPath} already exists; ${change} would make it unreadable`, {
106
+ hint: "drop --generate-key / --encryption-key-stdin to keep using this database, or move it aside (keep a backup) and re-run to start an encrypted one",
107
+ });
108
+ }
109
+ async function applyConvergedConfig(converged, dbEncryptionKey) {
110
+ const patch = { ...converged };
111
+ if (dbEncryptionKey !== undefined)
112
+ patch.dbEncryptionKey = dbEncryptionKey;
113
+ saveConfig(patch);
114
+ // openDb() runs the migration against the (freshly) configured db path.
115
+ const db = await openDb();
116
+ // Seeds structural accounts the ledger auto-references so first ingest resolves them; idempotent.
117
+ const { ensureStructuralAccount } = await import("../../accounts/accounts.js");
118
+ for (const id of ["expense:uncategorized", "equity:adjustments", "equity:opening-balance"]) {
119
+ ensureStructuralAccount(db, id);
120
+ }
121
+ // createContextTemplate no-ops if the file exists, so this never clobbers edits.
122
+ const { createContextTemplate } = await import("../../context.js");
123
+ createContextTemplate(converged.userName);
124
+ }
125
+ /**
126
+ * Idempotent: each value resolves to an explicit flag or the already-loaded
127
+ * singleton, so converging with no new flags is a no-op.
128
+ */
129
+ async function convergeConfig(flags) {
130
+ if (flags.generate_key && flags.encryption_key_stdin) {
131
+ fail("USAGE", "--generate-key and --encryption-key-stdin are mutually exclusive");
132
+ }
133
+ const converged = resolveConvergedConfig(flags);
134
+ mkdirSync(converged.dataDir, { recursive: true });
135
+ const dbEncryptionKey = await resolveEncryptionKey(flags);
136
+ guardKeyChange(dbEncryptionKey, converged.dbPath);
137
+ await applyConvergedConfig(converged, dbEncryptionKey);
138
+ printConfig(currentMode(), {
139
+ ...redactConfig(appConfig),
140
+ created: { config: getConfigPath(), db: converged.dbPath, data_dir: converged.dataDir },
141
+ });
142
+ }
143
+ async function configureHarness(opts) {
144
+ const flags = parseInput(CONVERGE_FLAGS_SPEC, opts);
145
+ if (Object.keys(flags).length > 0) {
146
+ await convergeConfig(flags);
147
+ }
148
+ else {
149
+ printConfig(currentMode(), showPayload());
150
+ }
151
+ }
152
+ function showConfig() {
153
+ printConfig(currentMode(), showPayload());
154
+ }
155
+ export function registerConfig(program) {
156
+ const configCmd = program
157
+ .command("config")
158
+ .enablePositionalOptions()
159
+ .description("Configuration")
160
+ .option("--data-dir <dir>", "data directory")
161
+ .option("--db <path>", "database path")
162
+ .option("--generate-key", "generate a new encryption key")
163
+ .option("--encryption-key-stdin", "read an encryption key from stdin")
164
+ .option("--locale <locale>", "locale")
165
+ .option("--currency <code>", "default currency code")
166
+ .option("--country <code>", "seed locale/currency from a country's defaults (default: th)")
167
+ .option("--user-name <name>", "user display name")
168
+ .option("--ocr-url <url>", "OCR endpoint base URL, e.g. http://127.0.0.1:1234/v1 (enables OCR on its own)")
169
+ .option("--ocr-model <id>", "OCR model id served at --ocr-url; picks the built-in prompt and render profile")
170
+ .action(runAction(configureHarness));
171
+ configCmd
172
+ .command("show")
173
+ .description("Show the current configuration")
174
+ .action(runAction(showConfig));
175
+ }