@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,313 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
2
|
+
import { resolveOnePosting, resolveMerchantId, } from "../accounts/resolve.js";
|
|
3
|
+
import { findAccountCurrency } from "../db/queries/accounts.js";
|
|
4
|
+
import { insertTransaction, insertLinkedTransactions, validateTransaction, } from "../db/queries/transactions.js";
|
|
5
|
+
import { deriveTransactionId, deriveGroupId, newTransactionId, newGroupId } from "../lib/ids.js";
|
|
6
|
+
import { toMinorUnits } from "../lib/money.js";
|
|
7
|
+
import { ISO_DATE_RE } from "../lib/date.js";
|
|
8
|
+
import { recordQuestion } from "../db/queries/questions.js";
|
|
9
|
+
/**
|
|
10
|
+
* Ledger-design §5: both legs must share a currency (derived from the accounts,
|
|
11
|
+
* never trusted from input); a cross-currency move needs a linked conversion pair.
|
|
12
|
+
*/
|
|
13
|
+
export const CURRENCY_MISMATCH_HINT = "add a linked conversion pair (one leg per currency, sharing a group)";
|
|
14
|
+
const NON_WORD = /[^\p{L}\p{N}]+/gu;
|
|
15
|
+
function normalizeForKey(raw) {
|
|
16
|
+
return raw.toLowerCase().replace(NON_WORD, " ").replace(/\s+/g, " ").trim();
|
|
17
|
+
}
|
|
18
|
+
function descriptorKey(descriptor) {
|
|
19
|
+
return `descriptor:${normalizeForKey(descriptor)}`;
|
|
20
|
+
}
|
|
21
|
+
function accountIdKey(id) {
|
|
22
|
+
return `account:${id}`;
|
|
23
|
+
}
|
|
24
|
+
function accountPairKey(a, b) {
|
|
25
|
+
const [lo, hi] = [a, b].sort();
|
|
26
|
+
return `account-pair:${lo}|${hi}`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Every raise() no-ops when `ctx.batchId` is null; raised questions attach to
|
|
30
|
+
* `transaction_id`, or none for pre-insert failures.
|
|
31
|
+
*/
|
|
32
|
+
export function defaultTransactionCommitHooks(db, ctx) {
|
|
33
|
+
const raise = (input) => {
|
|
34
|
+
if (!ctx.batchId)
|
|
35
|
+
return;
|
|
36
|
+
recordQuestion(db, { ...input, file_id: ctx.fileId, batch_id: ctx.batchId });
|
|
37
|
+
};
|
|
38
|
+
return {
|
|
39
|
+
onCommitted: () => { },
|
|
40
|
+
onDirtyInput: (input, reason) => raise({
|
|
41
|
+
transaction_id: null,
|
|
42
|
+
account_id: null,
|
|
43
|
+
kind: "dirty_input",
|
|
44
|
+
prompt: `The ingest input produced a transaction that couldn't be validated: ${reason}. ` +
|
|
45
|
+
`Raw description: "${input.description}" on ${input.date}.`,
|
|
46
|
+
context: { description: input.description, date: input.date, reason },
|
|
47
|
+
}),
|
|
48
|
+
onUnknownMerchant: (input, transactionId, attemptedId) => {
|
|
49
|
+
const descriptor = input.raw_descriptor || input.description;
|
|
50
|
+
raise({
|
|
51
|
+
transaction_id: transactionId,
|
|
52
|
+
account_id: null,
|
|
53
|
+
kind: "unknown_merchant",
|
|
54
|
+
prompt: `The ingest input referenced merchant id "${attemptedId}" but no such merchant exists. ` +
|
|
55
|
+
`Link "${descriptor}" to an existing merchant or leave it unlinked.`,
|
|
56
|
+
context: { rule_key: descriptorKey(descriptor), descriptor, attempted_id: attemptedId },
|
|
57
|
+
});
|
|
58
|
+
},
|
|
59
|
+
// Empty on purpose: a well-formed placeholder path is unambiguous, so it is
|
|
60
|
+
// recorded for the resolution summary but raises no question.
|
|
61
|
+
onPlaceholderAccount: () => { },
|
|
62
|
+
onUncategorizedFallback: (side, accountId, transactionId) => raise({
|
|
63
|
+
transaction_id: transactionId,
|
|
64
|
+
account_id: accountId,
|
|
65
|
+
kind: "uncategorized",
|
|
66
|
+
prompt: `The ${side} side couldn't be matched to a well-formed account and was booked to ` +
|
|
67
|
+
`"${accountId}". Recategorize it onto a real account, or re-run with a full ` +
|
|
68
|
+
`colon-path hint (e.g. expense:food:dining).`,
|
|
69
|
+
context: { rule_key: accountIdKey(accountId), placeholder_id: accountId, side },
|
|
70
|
+
}),
|
|
71
|
+
onSimilarAccount: (side, originalId, matchedId, transactionId) => raise({
|
|
72
|
+
transaction_id: transactionId,
|
|
73
|
+
account_id: matchedId,
|
|
74
|
+
kind: "similar_accounts",
|
|
75
|
+
prompt: `The ingest input referenced "${originalId}" for the ${side} side — the closest ` +
|
|
76
|
+
`existing account is "${matchedId}". Confirm they are the same, or split them apart.`,
|
|
77
|
+
context: {
|
|
78
|
+
rule_key: accountPairKey(originalId, matchedId),
|
|
79
|
+
original_id: originalId,
|
|
80
|
+
matched_id: matchedId,
|
|
81
|
+
side,
|
|
82
|
+
},
|
|
83
|
+
}),
|
|
84
|
+
onCurrencyMismatch: (input, debit, credit) => raise({
|
|
85
|
+
transaction_id: null,
|
|
86
|
+
account_id: null,
|
|
87
|
+
kind: "currency_mismatch",
|
|
88
|
+
prompt: `Transaction "${input.description}" on ${input.date} moves money between ` +
|
|
89
|
+
`${debit.id} (${debit.currency}) and ${credit.id} (${credit.currency}), which use ` +
|
|
90
|
+
`different currencies. A single transaction can't cross currencies — record it as a ` +
|
|
91
|
+
`linked conversion pair (one transaction out of ${debit.currency}, one into ` +
|
|
92
|
+
`${credit.currency}, sharing a group) so the FX conversion is explicit.`,
|
|
93
|
+
context: { debit, credit, date: input.date, description: input.description },
|
|
94
|
+
}),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function validateRawTransaction(input) {
|
|
98
|
+
if (!ISO_DATE_RE.test(input.date ?? "")) {
|
|
99
|
+
return { ok: false, reason: "date must be an ISO date (YYYY-MM-DD)." };
|
|
100
|
+
}
|
|
101
|
+
if (!input.description || !input.description.trim()) {
|
|
102
|
+
return { ok: false, reason: "description must not be empty." };
|
|
103
|
+
}
|
|
104
|
+
if (!input.debit_account_id || !input.debit_account_id.trim()) {
|
|
105
|
+
return { ok: false, reason: "debit_account_id must not be empty." };
|
|
106
|
+
}
|
|
107
|
+
if (!input.credit_account_id || !input.credit_account_id.trim()) {
|
|
108
|
+
return { ok: false, reason: "credit_account_id must not be empty." };
|
|
109
|
+
}
|
|
110
|
+
if (input.debit_account_id === input.credit_account_id) {
|
|
111
|
+
return { ok: false, reason: "debit and credit accounts must differ." };
|
|
112
|
+
}
|
|
113
|
+
if (typeof input.amount !== "number" || !Number.isFinite(input.amount) || input.amount <= 0) {
|
|
114
|
+
return { ok: false, reason: "amount must be a positive number." };
|
|
115
|
+
}
|
|
116
|
+
return { ok: true };
|
|
117
|
+
}
|
|
118
|
+
function accountCurrency(db, id) {
|
|
119
|
+
return findAccountCurrency(db, id) || config.displayCurrency;
|
|
120
|
+
}
|
|
121
|
+
/** Inputs are distinct (validated upstream), so both sides landing on one account
|
|
122
|
+
* means fuzzy matching over-collapsed them: that side is re-resolved with `skipFuzzy`.
|
|
123
|
+
* A non-fuzzy collision is left for the dirty_input backstop to catch. */
|
|
124
|
+
function resolveTransactionAccounts(db, debitAccountId, creditAccountId) {
|
|
125
|
+
let debitRes = resolveOnePosting(db, { account_id: debitAccountId });
|
|
126
|
+
let creditRes = resolveOnePosting(db, { account_id: creditAccountId });
|
|
127
|
+
let debitId = debitRes.posting.account_id;
|
|
128
|
+
let creditId = creditRes.posting.account_id;
|
|
129
|
+
if (debitId === creditId && debitRes.hint?.type === "similar_matched") {
|
|
130
|
+
debitRes = resolveOnePosting(db, { account_id: debitAccountId }, { skipFuzzy: true });
|
|
131
|
+
debitId = debitRes.posting.account_id;
|
|
132
|
+
}
|
|
133
|
+
if (debitId === creditId && creditRes.hint?.type === "similar_matched") {
|
|
134
|
+
creditRes = resolveOnePosting(db, { account_id: creditAccountId }, { skipFuzzy: true });
|
|
135
|
+
creditId = creditRes.posting.account_id;
|
|
136
|
+
}
|
|
137
|
+
const hints = [];
|
|
138
|
+
if (debitRes.hint)
|
|
139
|
+
hints.push({ side: "debit", hint: debitRes.hint });
|
|
140
|
+
if (creditRes.hint)
|
|
141
|
+
hints.push({ side: "credit", hint: creditRes.hint });
|
|
142
|
+
return { debitId, creditId, hints };
|
|
143
|
+
}
|
|
144
|
+
/** Currency comes from the resolved accounts, never from input (ledger-design §5).
|
|
145
|
+
* A cross-currency pair is reported as a mismatch rather than merged. */
|
|
146
|
+
function deriveTransactionCurrency(db, debitId, creditId) {
|
|
147
|
+
const debitCur = accountCurrency(db, debitId);
|
|
148
|
+
const creditCur = accountCurrency(db, creditId);
|
|
149
|
+
if (debitCur !== creditCur) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
debit: { id: debitId, currency: debitCur },
|
|
153
|
+
credit: { id: creditId, currency: creditCur },
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return { ok: true, currency: debitCur };
|
|
157
|
+
}
|
|
158
|
+
/** Doesn't touch the transactions table, but resolving may create placeholder
|
|
159
|
+
* accounts — on a currency mismatch those side effects remain with nothing inserted. */
|
|
160
|
+
function prepareTransaction(db, ctx, input) {
|
|
161
|
+
const raw = validateRawTransaction(input);
|
|
162
|
+
if (!raw.ok)
|
|
163
|
+
return { ok: false, reason: "dirty_input", message: raw.reason };
|
|
164
|
+
const { debitId, creditId, hints } = resolveTransactionAccounts(db, input.debit_account_id, input.credit_account_id);
|
|
165
|
+
const cur = deriveTransactionCurrency(db, debitId, creditId);
|
|
166
|
+
if (!cur.ok) {
|
|
167
|
+
return {
|
|
168
|
+
ok: false,
|
|
169
|
+
reason: "currency_mismatch",
|
|
170
|
+
message: `debit ${cur.debit.id} is ${cur.debit.currency}, credit ${cur.credit.id} is ${cur.credit.currency}`,
|
|
171
|
+
debit: cur.debit,
|
|
172
|
+
credit: cur.credit,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const currency = cur.currency;
|
|
176
|
+
const currencyOverridden = !!input.currency && input.currency !== currency;
|
|
177
|
+
const amountMinor = toMinorUnits(input.amount, currency);
|
|
178
|
+
const merchant = resolveMerchantId(db, input.merchant_id);
|
|
179
|
+
const id = ctx.fileHash && input.row_index != null
|
|
180
|
+
? deriveTransactionId(ctx.fileHash, input.source_page ?? 0, input.row_index, input.leg_index ?? undefined)
|
|
181
|
+
: input.id ?? newTransactionId();
|
|
182
|
+
const built = {
|
|
183
|
+
id,
|
|
184
|
+
group_id: input.group_id ?? null,
|
|
185
|
+
date: input.date,
|
|
186
|
+
description: input.description,
|
|
187
|
+
merchant_id: merchant.merchantId,
|
|
188
|
+
merchant: input.merchant ?? null,
|
|
189
|
+
raw_descriptor: input.raw_descriptor ?? null,
|
|
190
|
+
source_file_id: input.source_file_id ?? ctx.fileId ?? null,
|
|
191
|
+
source_page: input.source_page ?? null,
|
|
192
|
+
debit_account_id: debitId,
|
|
193
|
+
credit_account_id: creditId,
|
|
194
|
+
amount: amountMinor,
|
|
195
|
+
currency,
|
|
196
|
+
code: input.code ?? null,
|
|
197
|
+
user_ref: input.user_ref ?? null,
|
|
198
|
+
};
|
|
199
|
+
// Backstop: resolution can collapse two ids onto one account, which
|
|
200
|
+
// validateTransaction catches as debit == credit.
|
|
201
|
+
const v = validateTransaction(built);
|
|
202
|
+
if (!v.ok)
|
|
203
|
+
return { ok: false, reason: "dirty_input", message: v.reason };
|
|
204
|
+
return { ok: true, prepared: { input: built, hints, merchant, currencyOverridden, raw: input } };
|
|
205
|
+
}
|
|
206
|
+
function applyTransactionHints(hooks, transactionId, prepared) {
|
|
207
|
+
let raised = 0;
|
|
208
|
+
if (prepared.merchant.attemptedUnknownId) {
|
|
209
|
+
hooks.onUnknownMerchant(prepared.raw, transactionId, prepared.merchant.attemptedUnknownId);
|
|
210
|
+
raised++;
|
|
211
|
+
}
|
|
212
|
+
for (const { side, hint } of prepared.hints) {
|
|
213
|
+
if (hint.type === "placeholder_created") {
|
|
214
|
+
hooks.onPlaceholderAccount(side, hint.accountId, transactionId);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (hint.type === "uncategorized_fallback") {
|
|
218
|
+
hooks.onUncategorizedFallback(side, hint.accountId, transactionId);
|
|
219
|
+
raised++;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
hooks.onSimilarAccount(side, hint.originalId, hint.matchedId, transactionId);
|
|
223
|
+
raised++;
|
|
224
|
+
}
|
|
225
|
+
return raised;
|
|
226
|
+
}
|
|
227
|
+
/** A duplicate re-commit is a no-op: no questions raised, no balance change. */
|
|
228
|
+
export function commitTransaction(db, ctx, input, hooks = defaultTransactionCommitHooks(db, ctx)) {
|
|
229
|
+
const prep = prepareTransaction(db, ctx, input);
|
|
230
|
+
if (!prep.ok) {
|
|
231
|
+
if (prep.reason === "currency_mismatch") {
|
|
232
|
+
hooks.onCurrencyMismatch(input, prep.debit, prep.credit);
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
hooks.onDirtyInput(input, prep.message);
|
|
236
|
+
}
|
|
237
|
+
return { ok: false, reason: prep.reason, message: prep.message, raisedQuestions: 1 };
|
|
238
|
+
}
|
|
239
|
+
const { id, duplicate } = insertTransaction(db, prep.prepared.input);
|
|
240
|
+
if (duplicate) {
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
transactionId: id,
|
|
244
|
+
duplicate: true,
|
|
245
|
+
raisedQuestions: 0,
|
|
246
|
+
currencyOverridden: prep.prepared.currencyOverridden,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
hooks.onCommitted(id);
|
|
250
|
+
const raised = applyTransactionHints(hooks, id, prep.prepared);
|
|
251
|
+
return {
|
|
252
|
+
ok: true,
|
|
253
|
+
transactionId: id,
|
|
254
|
+
duplicate: false,
|
|
255
|
+
raisedQuestions: raised,
|
|
256
|
+
currencyOverridden: prep.prepared.currencyOverridden,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function mergeHeaderLeg(header, leg, groupId, legIndex) {
|
|
260
|
+
return {
|
|
261
|
+
group_id: groupId,
|
|
262
|
+
date: header.date,
|
|
263
|
+
description: leg.description ?? header.description,
|
|
264
|
+
raw_descriptor: header.raw_descriptor ?? null,
|
|
265
|
+
source_file_id: header.source_file_id ?? null,
|
|
266
|
+
source_page: header.source_page ?? null,
|
|
267
|
+
merchant: header.merchant ?? null,
|
|
268
|
+
merchant_id: header.merchant_id ?? null,
|
|
269
|
+
debit_account_id: leg.debit_account_id,
|
|
270
|
+
credit_account_id: leg.credit_account_id,
|
|
271
|
+
amount: leg.amount,
|
|
272
|
+
currency: leg.currency ?? null,
|
|
273
|
+
code: leg.code ?? null,
|
|
274
|
+
user_ref: leg.user_ref ?? null,
|
|
275
|
+
row_index: header.row_index ?? null,
|
|
276
|
+
leg_index: legIndex,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
/** Atomic under one group_id: every leg is prepared first; if any fails, nothing is inserted. */
|
|
280
|
+
export function commitLinkedTransactions(db, ctx, header, legs, hooks = defaultTransactionCommitHooks(db, ctx)) {
|
|
281
|
+
if (legs.length === 0) {
|
|
282
|
+
return { ok: false, reason: "dirty_input", message: "linked transaction has no legs.", raisedQuestions: 0 };
|
|
283
|
+
}
|
|
284
|
+
const groupId = header.group_id ??
|
|
285
|
+
(ctx.fileHash && header.row_index != null
|
|
286
|
+
? deriveGroupId(ctx.fileHash, header.source_page ?? 0, header.row_index)
|
|
287
|
+
: newGroupId());
|
|
288
|
+
const preps = [];
|
|
289
|
+
for (let i = 0; i < legs.length; i++) {
|
|
290
|
+
const raw = mergeHeaderLeg(header, legs[i], groupId, i);
|
|
291
|
+
const prep = prepareTransaction(db, ctx, raw);
|
|
292
|
+
if (!prep.ok) {
|
|
293
|
+
if (prep.reason === "currency_mismatch") {
|
|
294
|
+
hooks.onCurrencyMismatch(raw, prep.debit, prep.credit);
|
|
295
|
+
}
|
|
296
|
+
else {
|
|
297
|
+
hooks.onDirtyInput(raw, prep.message);
|
|
298
|
+
}
|
|
299
|
+
return { ok: false, reason: prep.reason, message: prep.message, raisedQuestions: 1 };
|
|
300
|
+
}
|
|
301
|
+
preps.push(prep.prepared);
|
|
302
|
+
}
|
|
303
|
+
const { results, group_id } = insertLinkedTransactions(db, preps.map((p) => p.input), { group_id: groupId });
|
|
304
|
+
let raised = 0;
|
|
305
|
+
for (let i = 0; i < preps.length; i++) {
|
|
306
|
+
const r = results[i];
|
|
307
|
+
if (r.duplicate)
|
|
308
|
+
continue;
|
|
309
|
+
hooks.onCommitted(r.id);
|
|
310
|
+
raised += applyTransactionHints(hooks, r.id, preps[i]);
|
|
311
|
+
}
|
|
312
|
+
return { ok: true, group_id, results, raisedQuestions: raised };
|
|
313
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { deleteTransaction } from "../db/queries/transactions.js";
|
|
2
|
+
import { findDuplicateTransactions, } from "../db/queries/transactions-dedup.js";
|
|
3
|
+
/**
|
|
4
|
+
* Within each group, keeps the earliest transaction and deletes exact
|
|
5
|
+
* matches on merchant, file, date, and amount (integer minor units, no
|
|
6
|
+
* rounding needed). The group finder already excludes linked legs.
|
|
7
|
+
*/
|
|
8
|
+
export function autoMergeStrictDuplicateTransactions(db) {
|
|
9
|
+
let merged = 0;
|
|
10
|
+
for (const group of findDuplicateTransactions(db)) {
|
|
11
|
+
merged += autoMergeStrictGroup(db, group);
|
|
12
|
+
}
|
|
13
|
+
return { merged };
|
|
14
|
+
}
|
|
15
|
+
function autoMergeStrictGroup(db, group) {
|
|
16
|
+
const sorted = [...group].sort((a, b) => {
|
|
17
|
+
const d = a.date.localeCompare(b.date);
|
|
18
|
+
return d !== 0 ? d : a.id.localeCompare(b.id);
|
|
19
|
+
});
|
|
20
|
+
const head = sorted[0];
|
|
21
|
+
if (!head.merchant_id || !head.source_file_id)
|
|
22
|
+
return 0;
|
|
23
|
+
let deleted = 0;
|
|
24
|
+
for (let i = 1; i < sorted.length; i++) {
|
|
25
|
+
const cand = sorted[i];
|
|
26
|
+
if (cand.merchant_id === head.merchant_id &&
|
|
27
|
+
cand.source_file_id === head.source_file_id &&
|
|
28
|
+
cand.date === head.date &&
|
|
29
|
+
cand.amount === head.amount) {
|
|
30
|
+
deleteTransaction(db, cand.id);
|
|
31
|
+
deleted++;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return deleted;
|
|
35
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { extname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { config, getCacheDir, getDataDir } from "../config.js";
|
|
5
|
+
import { findFileByHash, findFileById, insertPendingFile, replaceFile, } from "../db/queries/files.js";
|
|
6
|
+
import { extractFile } from "../extract/extract.js";
|
|
7
|
+
import { resolveOcr } from "../extract/ocr.js";
|
|
8
|
+
import { isEncryptedPdf } from "../extract/pdf.js";
|
|
9
|
+
import { SOURCES, loadSource } from "../extract/source.js";
|
|
10
|
+
import { tryExecute } from "../lib/result.js";
|
|
11
|
+
import { findCandidates, unlockNonInteractive } from "./vault.js";
|
|
12
|
+
function walk(dir, root, out) {
|
|
13
|
+
const entries = tryExecute(() => readdirSync(dir));
|
|
14
|
+
if (!entries.ok)
|
|
15
|
+
return;
|
|
16
|
+
for (const entry of entries.value) {
|
|
17
|
+
if (entry.startsWith("."))
|
|
18
|
+
continue;
|
|
19
|
+
const full = resolve(dir, entry);
|
|
20
|
+
const stat = tryExecute(() => statSync(full));
|
|
21
|
+
if (!stat.ok)
|
|
22
|
+
continue;
|
|
23
|
+
if (stat.value.isDirectory()) {
|
|
24
|
+
walk(full, root, out);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (!stat.value.isFile())
|
|
28
|
+
continue;
|
|
29
|
+
if (!SOURCES[extname(entry).toLowerCase()])
|
|
30
|
+
continue;
|
|
31
|
+
out.push({ path: full, relPath: relative(root, full).split(sep).join("/") });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const UNLOCKED = { encrypted: false, vaultCandidates: 0 };
|
|
35
|
+
/** Only a PDF can be locked, and only a PDF can be unopenable — an image needs neither probe. */
|
|
36
|
+
async function encryptionOf(db, source) {
|
|
37
|
+
if (source.kind !== "pdf")
|
|
38
|
+
return { ok: true, value: UNLOCKED };
|
|
39
|
+
const probe = await tryExecute(() => isEncryptedPdf(source.bytes));
|
|
40
|
+
if (!probe.ok)
|
|
41
|
+
return probe;
|
|
42
|
+
if (!probe.value)
|
|
43
|
+
return { ok: true, value: UNLOCKED };
|
|
44
|
+
return {
|
|
45
|
+
ok: true,
|
|
46
|
+
value: {
|
|
47
|
+
encrypted: true,
|
|
48
|
+
vaultCandidates: findCandidates(db, source.path, config.dbEncryptionKey).length,
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
function unreadableEntry(file, note) {
|
|
53
|
+
return { ...file, hash: null, fileId: null, status: "unreadable", ...UNLOCKED, note };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* A file this harness can't read becomes an `unreadable` row, rather than
|
|
57
|
+
* sinking the whole listing.
|
|
58
|
+
*/
|
|
59
|
+
export async function discoverFiles(db, opts = {}) {
|
|
60
|
+
const root = getDataDir();
|
|
61
|
+
const walked = [];
|
|
62
|
+
walk(root, root, walked);
|
|
63
|
+
const entries = [];
|
|
64
|
+
for (const file of walked) {
|
|
65
|
+
if (opts.regex && !opts.regex.test(file.relPath))
|
|
66
|
+
continue;
|
|
67
|
+
const loaded = loadSource(file.path);
|
|
68
|
+
if (!loaded.ok) {
|
|
69
|
+
entries.push(unreadableEntry(file, loaded.message));
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const encryption = await encryptionOf(db, loaded.value);
|
|
73
|
+
if (!encryption.ok) {
|
|
74
|
+
entries.push(unreadableEntry(file, encryption.error));
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const known = findFileByHash(db, loaded.value.hash);
|
|
78
|
+
entries.push({
|
|
79
|
+
...file,
|
|
80
|
+
hash: loaded.value.hash,
|
|
81
|
+
fileId: known?.id ?? null,
|
|
82
|
+
status: known ? known.status : "new",
|
|
83
|
+
...encryption.value,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
return entries;
|
|
87
|
+
}
|
|
88
|
+
function newFileId() {
|
|
89
|
+
return `sf:${randomUUID()}`;
|
|
90
|
+
}
|
|
91
|
+
function pendingRow(source, fileId) {
|
|
92
|
+
return { id: fileId, path: source.path, file_hash: source.hash, mime: source.mime };
|
|
93
|
+
}
|
|
94
|
+
/** Pending row keyed by content hash, so re-registering the same bytes is a
|
|
95
|
+
* no-op that returns the existing id. */
|
|
96
|
+
export function registerPendingFile(db, source) {
|
|
97
|
+
const known = findFileByHash(db, source.hash);
|
|
98
|
+
if (known)
|
|
99
|
+
return { fileId: known.id, alreadyKnown: true };
|
|
100
|
+
const fileId = newFileId();
|
|
101
|
+
insertPendingFile(db, pendingRow(source, fileId));
|
|
102
|
+
return { fileId, alreadyKnown: false };
|
|
103
|
+
}
|
|
104
|
+
/** Resolution order: absolute, data-dir-relative, cwd-relative, then `sf:` file id; null if none match. */
|
|
105
|
+
export function resolveEntryPath(db, entryOrId) {
|
|
106
|
+
if (isAbsolute(entryOrId) && existsSync(entryOrId))
|
|
107
|
+
return entryOrId;
|
|
108
|
+
const viaDataDir = resolve(getDataDir(), entryOrId);
|
|
109
|
+
if (existsSync(viaDataDir))
|
|
110
|
+
return viaDataDir;
|
|
111
|
+
const viaCwd = resolve(entryOrId);
|
|
112
|
+
if (existsSync(viaCwd))
|
|
113
|
+
return viaCwd;
|
|
114
|
+
const byId = findFileById(db, entryOrId);
|
|
115
|
+
if (byId)
|
|
116
|
+
return byId.path;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
function replaceFileRow(db, priorId, source, fileId) {
|
|
120
|
+
replaceFile(db, priorId, pendingRow(source, fileId));
|
|
121
|
+
cleanCache(priorId);
|
|
122
|
+
}
|
|
123
|
+
const UNLOCK_FAILED = {
|
|
124
|
+
password_required: "the PDF is locked and no stored password opened it",
|
|
125
|
+
wrong_password: "the supplied password did not open the PDF",
|
|
126
|
+
};
|
|
127
|
+
/** Images are never locked; decrypted PDF bytes stay in memory — only extracted text is written to disk. */
|
|
128
|
+
async function readableBytes(db, source, password) {
|
|
129
|
+
if (source.kind !== "pdf")
|
|
130
|
+
return { ok: true, bytes: source.bytes };
|
|
131
|
+
// Passes an already-unlocked source through untouched; throws on bytes mupdf can't open at all.
|
|
132
|
+
const attempt = await tryExecute(() => unlockNonInteractive(db, source.bytes, source.path, { password }));
|
|
133
|
+
if (!attempt.ok)
|
|
134
|
+
return { ok: false, reason: "pdf_unreadable", message: attempt.error };
|
|
135
|
+
const unlocked = attempt.value;
|
|
136
|
+
if (!unlocked.ok) {
|
|
137
|
+
return { ok: false, reason: unlocked.reason, message: UNLOCK_FAILED[unlocked.reason] };
|
|
138
|
+
}
|
|
139
|
+
return { ok: true, bytes: unlocked.decrypted };
|
|
140
|
+
}
|
|
141
|
+
const DOCUMENT_FILE = "document.txt";
|
|
142
|
+
/** Markers give every row a 1-based page to cite as `source_page`. */
|
|
143
|
+
function documentText(pages) {
|
|
144
|
+
return `${pages.map((page) => `--- page ${page.page} ---\n${page.text}`).join("\n\n")}\n`;
|
|
145
|
+
}
|
|
146
|
+
/** Rebuilt per prepare, so a prior run's artifacts can never be mistaken for this one's. */
|
|
147
|
+
function freshDir(dir) {
|
|
148
|
+
rmSync(dir, { recursive: true, force: true });
|
|
149
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
150
|
+
}
|
|
151
|
+
function writeDocument(extraction, target) {
|
|
152
|
+
freshDir(target.dir);
|
|
153
|
+
const document = resolve(target.dir, DOCUMENT_FILE);
|
|
154
|
+
writeFileSync(document, documentText(extraction.pages), { mode: 0o600 });
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
fileId: target.fileId,
|
|
158
|
+
kind: "text",
|
|
159
|
+
source: extraction.source,
|
|
160
|
+
textLayer: extraction.textLayer,
|
|
161
|
+
model: extraction.model,
|
|
162
|
+
pageCount: extraction.pages.length,
|
|
163
|
+
document,
|
|
164
|
+
pages: extraction.pages.map((page) => ({ page: page.page, chars: page.text.length })),
|
|
165
|
+
failedPages: extraction.failedPages,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function writePages(extraction, target) {
|
|
169
|
+
const head = {
|
|
170
|
+
ok: true,
|
|
171
|
+
fileId: target.fileId,
|
|
172
|
+
kind: "images",
|
|
173
|
+
source: extraction.source,
|
|
174
|
+
textLayer: extraction.textLayer,
|
|
175
|
+
pageCount: extraction.pages.length,
|
|
176
|
+
dpi: extraction.dpi,
|
|
177
|
+
};
|
|
178
|
+
// An untouched image is read where it lies; only a prior run's artifacts could be there to mislead.
|
|
179
|
+
if (extraction.source === "original") {
|
|
180
|
+
rmSync(target.dir, { recursive: true, force: true });
|
|
181
|
+
const pages = extraction.pages.map((page) => ({ page: page.page, path: target.sourcePath }));
|
|
182
|
+
return { ...head, pages };
|
|
183
|
+
}
|
|
184
|
+
freshDir(target.dir);
|
|
185
|
+
const pages = [];
|
|
186
|
+
for (const page of extraction.pages) {
|
|
187
|
+
const path = resolve(target.dir, `p${page.page}.png`);
|
|
188
|
+
writeFileSync(path, page.bytes, { mode: 0o600 });
|
|
189
|
+
pages.push({ page: page.page, path });
|
|
190
|
+
}
|
|
191
|
+
return { ...head, pages };
|
|
192
|
+
}
|
|
193
|
+
const WRITE_ARTIFACTS = {
|
|
194
|
+
text: writeDocument,
|
|
195
|
+
images: writePages,
|
|
196
|
+
};
|
|
197
|
+
/** A failure leaves the ledger exactly as it found it. */
|
|
198
|
+
export async function prepareFile(db, entryOrId, opts = {}) {
|
|
199
|
+
const absPath = resolveEntryPath(db, entryOrId);
|
|
200
|
+
if (absPath === null) {
|
|
201
|
+
return { ok: false, reason: "not_found", message: `no ingest entry or file at "${entryOrId}"` };
|
|
202
|
+
}
|
|
203
|
+
const loaded = loadSource(absPath);
|
|
204
|
+
if (!loaded.ok)
|
|
205
|
+
return { ok: false, reason: loaded.reason, message: loaded.message };
|
|
206
|
+
const source = loaded.value;
|
|
207
|
+
// Every case but --force's swap (see replaceFileRow) registers now: a file
|
|
208
|
+
// that never opens still needs an id for `ingest fail`.
|
|
209
|
+
const prior = opts.force ? findFileByHash(db, source.hash) : null;
|
|
210
|
+
const fileId = prior ? newFileId() : registerPendingFile(db, source).fileId;
|
|
211
|
+
const readable = await readableBytes(db, source, opts.password);
|
|
212
|
+
if (!readable.ok)
|
|
213
|
+
return readable;
|
|
214
|
+
const extracted = await extractFile({ kind: source.kind, mime: source.mime, bytes: readable.bytes, path: source.path }, { ocr: resolveOcr(), overrides: { rescan: opts.rescan, noOcr: opts.noOcr } });
|
|
215
|
+
if (!extracted.ok)
|
|
216
|
+
return { ok: false, reason: extracted.reason, message: extracted.message };
|
|
217
|
+
const target = {
|
|
218
|
+
fileId,
|
|
219
|
+
dir: resolve(getCacheDir(), fileId),
|
|
220
|
+
sourcePath: source.path,
|
|
221
|
+
};
|
|
222
|
+
const written = WRITE_ARTIFACTS[extracted.value.kind](extracted.value, target);
|
|
223
|
+
if (prior)
|
|
224
|
+
replaceFileRow(db, prior.id, source, fileId);
|
|
225
|
+
return written;
|
|
226
|
+
}
|
|
227
|
+
export function cleanCache(fileId) {
|
|
228
|
+
const base = getCacheDir();
|
|
229
|
+
if (fileId) {
|
|
230
|
+
const dir = resolve(base, fileId);
|
|
231
|
+
if (!existsSync(dir))
|
|
232
|
+
return { removed: [] };
|
|
233
|
+
rmSync(dir, { recursive: true, force: true });
|
|
234
|
+
return { removed: [dir] };
|
|
235
|
+
}
|
|
236
|
+
if (!existsSync(base))
|
|
237
|
+
return { removed: [] };
|
|
238
|
+
const removed = readdirSync(base).map((name) => resolve(base, name));
|
|
239
|
+
rmSync(base, { recursive: true, force: true });
|
|
240
|
+
return { removed };
|
|
241
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
import { listPasswordSecrets, recordPasswordUse, upsertPassword, } from "../db/queries/vault.js";
|
|
4
|
+
import { isEncryptedPdf, unlockPdf } from "../extract/pdf.js";
|
|
5
|
+
// The SQL and the at-rest encryption live in src/db/queries/vault.ts; the
|
|
6
|
+
// mupdf mechanism in src/extract/pdf.ts.
|
|
7
|
+
const REGEX_META = /[.*+?^${}()|[\]\\]/g;
|
|
8
|
+
const SEPARATORS = /[_\-\s.]/;
|
|
9
|
+
const MIN_PREFIX_LEN = 3;
|
|
10
|
+
/** Short or non-alpha prefixes fall back to escaped+digit-collapse to avoid `^a.*`-style false positives. */
|
|
11
|
+
function suggestPattern(filename) {
|
|
12
|
+
const name = basename(filename).toLowerCase();
|
|
13
|
+
const prefix = name.split(SEPARATORS)[0];
|
|
14
|
+
if (prefix.length >= MIN_PREFIX_LEN && /^[a-z]/.test(prefix)) {
|
|
15
|
+
return `^${prefix.replace(REGEX_META, "\\$&")}.*`;
|
|
16
|
+
}
|
|
17
|
+
const escaped = name.replace(REGEX_META, "\\$&");
|
|
18
|
+
const collapsed = escaped.replace(/\d+/g, "\\d+");
|
|
19
|
+
return `^${collapsed}$`;
|
|
20
|
+
}
|
|
21
|
+
/** A pattern that no longer compiles must not sink the whole lookup. */
|
|
22
|
+
function safeTest(pattern, target) {
|
|
23
|
+
try {
|
|
24
|
+
return new RegExp(pattern, "i").test(target);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** Stored passwords whose pattern matches this file's name, most-used first. */
|
|
31
|
+
export function findCandidates(db, filePath, dbKey) {
|
|
32
|
+
const target = basename(filePath);
|
|
33
|
+
return listPasswordSecrets(db, dbKey).filter((row) => safeTest(row.pattern, target));
|
|
34
|
+
}
|
|
35
|
+
/** Non-interactive unlock for the agent-CLI harness: no prompts, no spinners. */
|
|
36
|
+
export async function unlockNonInteractive(db, bytes, filename, opts) {
|
|
37
|
+
if (!(await isEncryptedPdf(bytes))) {
|
|
38
|
+
return { ok: true, decrypted: bytes };
|
|
39
|
+
}
|
|
40
|
+
for (const cand of findCandidates(db, filename, config.dbEncryptionKey)) {
|
|
41
|
+
const result = await unlockPdf(bytes, cand.password);
|
|
42
|
+
if (result.ok) {
|
|
43
|
+
recordPasswordUse(db, cand.id);
|
|
44
|
+
return { ok: true, decrypted: result.decrypted };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const password = opts.password ?? "";
|
|
48
|
+
if (!password)
|
|
49
|
+
return { ok: false, reason: "password_required" };
|
|
50
|
+
const result = await unlockPdf(bytes, password);
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
return { ok: false, reason: "wrong_password" };
|
|
53
|
+
}
|
|
54
|
+
upsertPassword(db, suggestPattern(filename), password, config.dbEncryptionKey);
|
|
55
|
+
return { ok: true, decrypted: result.decrypted };
|
|
56
|
+
}
|
package/dist/lib/date.js
ADDED
package/dist/lib/ids.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "crypto";
|
|
2
|
+
/**
|
|
3
|
+
* `tx:` + sha256("<hash>|<page>|<row>[|<leg>]") — deterministic, so re-ingesting the
|
|
4
|
+
* same file is idempotent. Omitting `legIndex` makes the hash match `deriveGroupId`'s.
|
|
5
|
+
*/
|
|
6
|
+
export function deriveTransactionId(fileHash, page, rowIndex, legIndex) {
|
|
7
|
+
const base = `${fileHash}|${page}|${rowIndex}`;
|
|
8
|
+
const material = legIndex != null ? `${base}|${legIndex}` : base;
|
|
9
|
+
return "tx:" + createHash("sha256").update(material).digest("hex").slice(0, 16);
|
|
10
|
+
}
|
|
11
|
+
/** `tg:` + the same hash as legless `deriveTransactionId(fileHash, page, rowIndex)`. */
|
|
12
|
+
export function deriveGroupId(fileHash, page, rowIndex) {
|
|
13
|
+
return "tg:" + createHash("sha256").update(`${fileHash}|${page}|${rowIndex}`).digest("hex").slice(0, 16);
|
|
14
|
+
}
|
|
15
|
+
/** Groups a commit run's raised questions. */
|
|
16
|
+
export function newBatchId() {
|
|
17
|
+
return `ib:${randomUUID()}`;
|
|
18
|
+
}
|
|
19
|
+
/** For a row without deterministic source coordinates. */
|
|
20
|
+
export function newTransactionId() {
|
|
21
|
+
return `tx:${randomUUID()}`;
|
|
22
|
+
}
|
|
23
|
+
export function newGroupId() {
|
|
24
|
+
return `tg:${randomUUID()}`;
|
|
25
|
+
}
|