@crewhaus/tool-ledger 0.7.0

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.
@@ -0,0 +1,480 @@
1
+ import { LedgerError, byString, dayDiff, epochDay, formatMajor } from "./amount";
2
+ import { NORMAL_BALANCE, verifyChain, } from "./db";
3
+ import { entryPayload } from "./post";
4
+ export const VIEWS = [
5
+ "trial_balance",
6
+ "balance",
7
+ "account_ledger",
8
+ "journal",
9
+ "pnl",
10
+ "balance_sheet",
11
+ "aging",
12
+ "search",
13
+ "chain",
14
+ ];
15
+ export const FORMATS = ["json", "csv", "markdown"];
16
+ /** Lines one call will pull out of SQLite before it refuses to continue. */
17
+ export const MAX_SCAN_ROWS = 200_000;
18
+ function selectLines(db, filter) {
19
+ const where = [];
20
+ const params = [];
21
+ if (filter.from !== undefined) {
22
+ where.push("e.date >= ?");
23
+ params.push(filter.from);
24
+ }
25
+ if (filter.to !== undefined) {
26
+ where.push("e.date <= ?");
27
+ params.push(filter.to);
28
+ }
29
+ if (filter.accounts !== undefined && filter.accounts.length > 0) {
30
+ where.push(`l.account IN (${filter.accounts.map(() => "?").join(", ")})`);
31
+ params.push(...filter.accounts);
32
+ }
33
+ if (filter.reference !== undefined) {
34
+ where.push("e.reference = ?");
35
+ params.push(filter.reference);
36
+ }
37
+ if (filter.sourceSystem !== undefined) {
38
+ where.push("e.source_system = ?");
39
+ params.push(filter.sourceSystem);
40
+ }
41
+ if (filter.counterparty !== undefined) {
42
+ where.push("l.counterparty = ?");
43
+ params.push(filter.counterparty);
44
+ }
45
+ const sql = `SELECT l.*, e.seq, e.entry_id, e.date, e.memo, e.reference, e.tags, e.source_system, e.source_id
46
+ FROM lines l JOIN entries e ON e.seq = l.entry_seq
47
+ ${where.length > 0 ? `WHERE ${where.join(" AND ")}` : ""}
48
+ ORDER BY e.seq ASC, l.idx ASC
49
+ LIMIT ${MAX_SCAN_ROWS + 1}`;
50
+ const rows = db.query(sql).all(...params);
51
+ if (rows.length > MAX_SCAN_ROWS) {
52
+ throw new LedgerError(`this question selects more than ${MAX_SCAN_ROWS} lines — narrow it with from/to or accounts; answering it would mean holding the whole ledger in memory and returning a number nobody can check`);
53
+ }
54
+ // The remaining predicates are applied here rather than in SQL: `tag` lives
55
+ // inside a JSON array, `text` is a case-insensitive scan, and the amount
56
+ // bounds compare bigints that SQLite would compare as text ("9" > "10").
57
+ return rows.filter((row) => {
58
+ if (filter.tag !== undefined) {
59
+ const tags = JSON.parse(row.tags);
60
+ if (!tags.includes(filter.tag))
61
+ return false;
62
+ }
63
+ if (filter.text !== undefined) {
64
+ const needle = filter.text.toLowerCase();
65
+ const hay = `${row.memo} ${row.reference} ${row.line_memo ?? ""} ${row.account}`.toLowerCase();
66
+ if (!hay.includes(needle))
67
+ return false;
68
+ }
69
+ if (filter.amountMinMinor !== undefined || filter.amountMaxMinor !== undefined) {
70
+ const magnitude = absolute(BigInt(row.base_debit_minor) - BigInt(row.base_credit_minor));
71
+ if (filter.amountMinMinor !== undefined && magnitude < BigInt(filter.amountMinMinor)) {
72
+ return false;
73
+ }
74
+ if (filter.amountMaxMinor !== undefined && magnitude > BigInt(filter.amountMaxMinor)) {
75
+ return false;
76
+ }
77
+ }
78
+ return true;
79
+ });
80
+ }
81
+ const absolute = (v) => (v < 0n ? -v : v);
82
+ /**
83
+ * Whether the selection can have split an entry across the filter boundary.
84
+ *
85
+ * `balanced` is only a statement about the books when the rows selected are
86
+ * WHOLE entries. Filtering by date, tag, reference or source keeps or drops an
87
+ * entry entirely, so the debits and credits still pair up; filtering by
88
+ * account, counterparty, text or amount keeps one leg and drops the other, and
89
+ * a trial balance over half an entry is unbalanced by construction. Reporting
90
+ * `false` there says "somebody edited the file" about a caller who typed a
91
+ * filter, which is worse than saying nothing.
92
+ */
93
+ function selectionSplitsEntries(filter) {
94
+ return ((filter.accounts !== undefined && filter.accounts.length > 0) ||
95
+ filter.counterparty !== undefined ||
96
+ filter.text !== undefined ||
97
+ filter.amountMinMinor !== undefined ||
98
+ filter.amountMaxMinor !== undefined);
99
+ }
100
+ function accountTypes(db) {
101
+ const rows = db.query("SELECT code, type FROM accounts").all();
102
+ return new Map(rows.map((r) => [r.code, r.type]));
103
+ }
104
+ function totalsByAccount(rows) {
105
+ const out = new Map();
106
+ for (const row of rows) {
107
+ const bucket = out.get(row.account) ?? { debit: 0n, credit: 0n };
108
+ bucket.debit += BigInt(row.base_debit_minor);
109
+ bucket.credit += BigInt(row.base_credit_minor);
110
+ out.set(row.account, bucket);
111
+ }
112
+ return out;
113
+ }
114
+ /** The balance on the account's own normal side, which is the one a reader expects. */
115
+ function signedBalance(type, totals) {
116
+ const normal = type === undefined ? "debit" : NORMAL_BALANCE[type];
117
+ return normal === "debit" ? totals.debit - totals.credit : totals.credit - totals.debit;
118
+ }
119
+ export function runQuery(db, options) {
120
+ const { view, baseExponent: exp } = options;
121
+ if (view === "chain") {
122
+ const chain = verifyChain(db, (entry, lines) => entryPayload(entry, lines));
123
+ return {
124
+ view,
125
+ rows: chain.problems.map((p) => ({ seq: p.seq, reason: p.reason })),
126
+ totals: { ok: chain.ok, entries: chain.entries, head: chain.head },
127
+ rowCount: chain.problems.length,
128
+ truncated: false,
129
+ // `ok` is a statement about the ENTRIES and their lines, which is what
130
+ // the chain covers. It is not a statement about the chart of accounts:
131
+ // an account's type is outside the hash, and flipping one from income to
132
+ // expense turns a P&L over without breaking a single link. Saying so
133
+ // here is the difference between "verified" and "verified what".
134
+ notes: [
135
+ 'this checks the entries and their lines. The chart of accounts is NOT inside the chain, so re-typing an account changes every report that signs by type and still verifies "ok" — compare the account types against your own record separately.',
136
+ ],
137
+ chain,
138
+ };
139
+ }
140
+ const rows = selectLines(db, options.filter);
141
+ const types = accountTypes(db);
142
+ const money = (v) => formatMajor(v, exp);
143
+ switch (view) {
144
+ case "trial_balance":
145
+ case "balance": {
146
+ const totals = totalsByAccount(rows);
147
+ const out = [...totals.entries()]
148
+ .sort((a, b) => byString(a[0], b[0]))
149
+ .map(([account, t]) => ({
150
+ account,
151
+ type: types.get(account) ?? null,
152
+ debitMinor: t.debit.toString(),
153
+ creditMinor: t.credit.toString(),
154
+ debit: money(t.debit),
155
+ credit: money(t.credit),
156
+ balanceMinor: signedBalance(types.get(account), t).toString(),
157
+ balance: money(signedBalance(types.get(account), t)),
158
+ }));
159
+ const debit = [...totals.values()].reduce((s, t) => s + t.debit, 0n);
160
+ const credit = [...totals.values()].reduce((s, t) => s + t.credit, 0n);
161
+ // Every posting was balance-checked, so over WHOLE entries this can only
162
+ // be false if the file was edited outside the tool — which is why it is
163
+ // reported rather than assumed. Over a selection that cuts entries in
164
+ // half it says nothing at all, so it says `null` and the note explains
165
+ // why; `false` there would read as tampering to whoever is looking.
166
+ const split = selectionSplitsEntries(options.filter);
167
+ return {
168
+ view,
169
+ rows: out.slice(0, options.limit),
170
+ totals: {
171
+ debitMinor: debit.toString(),
172
+ creditMinor: credit.toString(),
173
+ debit: money(debit),
174
+ credit: money(credit),
175
+ differenceMinor: (debit - credit).toString(),
176
+ balanced: split ? null : debit === credit,
177
+ currency: options.baseCurrency,
178
+ },
179
+ rowCount: out.length,
180
+ truncated: out.length > options.limit,
181
+ notes: split
182
+ ? [
183
+ 'this selection filters on individual lines, so it holds one leg of an entry and not the other; "balanced" is null because it cannot be judged here — run the same view with no account, counterparty, text or amount filter to check the books',
184
+ ]
185
+ : undefined,
186
+ };
187
+ }
188
+ case "account_ledger": {
189
+ const accounts = options.filter.accounts ?? [];
190
+ if (accounts.length !== 1) {
191
+ throw new LedgerError("account_ledger reports ONE account's running balance; pass exactly one account (a running balance across several accounts is a number with no meaning)");
192
+ }
193
+ let running = 0n;
194
+ const account = accounts[0];
195
+ const type = types.get(account);
196
+ const out = rows.map((row) => {
197
+ const delta = (type === undefined ? "debit" : NORMAL_BALANCE[type]) === "debit"
198
+ ? BigInt(row.base_debit_minor) - BigInt(row.base_credit_minor)
199
+ : BigInt(row.base_credit_minor) - BigInt(row.base_debit_minor);
200
+ running += delta;
201
+ return {
202
+ seq: row.seq,
203
+ entryId: row.entry_id,
204
+ date: row.date,
205
+ memo: row.line_memo ?? row.memo,
206
+ reference: row.reference,
207
+ counterparty: row.counterparty,
208
+ debit: money(BigInt(row.base_debit_minor)),
209
+ credit: money(BigInt(row.base_credit_minor)),
210
+ balanceMinor: running.toString(),
211
+ balance: money(running),
212
+ };
213
+ });
214
+ return {
215
+ view,
216
+ rows: out.slice(0, options.limit),
217
+ totals: {
218
+ account,
219
+ closingBalanceMinor: running.toString(),
220
+ closingBalance: money(running),
221
+ currency: options.baseCurrency,
222
+ },
223
+ rowCount: out.length,
224
+ truncated: out.length > options.limit,
225
+ };
226
+ }
227
+ case "journal":
228
+ case "search": {
229
+ const out = rows.map((row) => ({
230
+ seq: row.seq,
231
+ entryId: row.entry_id,
232
+ date: row.date,
233
+ account: row.account,
234
+ memo: row.line_memo ?? row.memo,
235
+ reference: row.reference,
236
+ counterparty: row.counterparty,
237
+ currency: row.currency,
238
+ debit: money(BigInt(row.base_debit_minor)),
239
+ credit: money(BigInt(row.base_credit_minor)),
240
+ debitMinor: row.base_debit_minor,
241
+ creditMinor: row.base_credit_minor,
242
+ source: row.source_system === null ? null : `${row.source_system}/${row.source_id}`,
243
+ tags: JSON.parse(row.tags).join(" "),
244
+ }));
245
+ const debit = rows.reduce((s, r) => s + BigInt(r.base_debit_minor), 0n);
246
+ const credit = rows.reduce((s, r) => s + BigInt(r.base_credit_minor), 0n);
247
+ return {
248
+ view,
249
+ rows: out.slice(0, options.limit),
250
+ totals: {
251
+ debit: money(debit),
252
+ credit: money(credit),
253
+ debitMinor: debit.toString(),
254
+ creditMinor: credit.toString(),
255
+ currency: options.baseCurrency,
256
+ },
257
+ rowCount: out.length,
258
+ truncated: out.length > options.limit,
259
+ };
260
+ }
261
+ case "pnl": {
262
+ const totals = totalsByAccount(rows);
263
+ const out = [];
264
+ let income = 0n;
265
+ let expense = 0n;
266
+ for (const [account, t] of [...totals.entries()].sort((a, b) => byString(a[0], b[0]))) {
267
+ const type = types.get(account);
268
+ if (type !== "income" && type !== "expense")
269
+ continue;
270
+ const amount = signedBalance(type, t);
271
+ if (type === "income")
272
+ income += amount;
273
+ else
274
+ expense += amount;
275
+ out.push({
276
+ account,
277
+ type,
278
+ amountMinor: amount.toString(),
279
+ amount: money(amount),
280
+ });
281
+ }
282
+ const net = income - expense;
283
+ return {
284
+ view,
285
+ rows: out.slice(0, options.limit),
286
+ totals: {
287
+ incomeMinor: income.toString(),
288
+ income: money(income),
289
+ expenseMinor: expense.toString(),
290
+ expense: money(expense),
291
+ netIncomeMinor: net.toString(),
292
+ netIncome: money(net),
293
+ currency: options.baseCurrency,
294
+ },
295
+ rowCount: out.length,
296
+ truncated: out.length > options.limit,
297
+ };
298
+ }
299
+ case "balance_sheet": {
300
+ const totals = totalsByAccount(rows);
301
+ const out = [];
302
+ const sums = {
303
+ asset: 0n,
304
+ liability: 0n,
305
+ equity: 0n,
306
+ income: 0n,
307
+ expense: 0n,
308
+ };
309
+ for (const [account, t] of [...totals.entries()].sort((a, b) => byString(a[0], b[0]))) {
310
+ const type = types.get(account);
311
+ if (type === undefined)
312
+ continue;
313
+ const amount = signedBalance(type, t);
314
+ sums[type] += amount;
315
+ if (type === "income" || type === "expense")
316
+ continue;
317
+ out.push({ account, type, amountMinor: amount.toString(), amount: money(amount) });
318
+ }
319
+ // Retained earnings for the period are not an account anybody posted to;
320
+ // they are income less expense, and the sheet does not balance without
321
+ // them. Reporting the difference rather than asserting it lets a caller
322
+ // see a book that has been edited.
323
+ const retained = sums.income - sums.expense;
324
+ const difference = sums.asset - (sums.liability + sums.equity + retained);
325
+ return {
326
+ view,
327
+ rows: out.slice(0, options.limit),
328
+ totals: {
329
+ asOf: options.asOf ?? options.filter.to ?? "all posted entries",
330
+ assetsMinor: sums.asset.toString(),
331
+ assets: money(sums.asset),
332
+ liabilitiesMinor: sums.liability.toString(),
333
+ liabilities: money(sums.liability),
334
+ equityMinor: sums.equity.toString(),
335
+ equity: money(sums.equity),
336
+ retainedEarningsMinor: retained.toString(),
337
+ retainedEarnings: money(retained),
338
+ differenceMinor: difference.toString(),
339
+ balanced: difference === 0n,
340
+ currency: options.baseCurrency,
341
+ },
342
+ rowCount: out.length,
343
+ truncated: out.length > options.limit,
344
+ };
345
+ }
346
+ case "aging":
347
+ return agingView(rows, options, money);
348
+ default: {
349
+ const exhaustive = view;
350
+ throw new LedgerError(`unknown view ${String(exhaustive)}`);
351
+ }
352
+ }
353
+ }
354
+ const BUCKETS = ["current", "1-30", "31-60", "61-90", "90+"];
355
+ function bucketFor(dueDate, asOf) {
356
+ const overdue = dayDiff(asOf, dueDate);
357
+ if (overdue <= 0)
358
+ return "current";
359
+ if (overdue <= 30)
360
+ return "1-30";
361
+ if (overdue <= 60)
362
+ return "31-60";
363
+ if (overdue <= 90)
364
+ return "61-90";
365
+ return "90+";
366
+ }
367
+ /**
368
+ * Receivables/payables aging, or a refusal naming what is missing.
369
+ *
370
+ * The failure this guards against is the one that looks like an answer: with
371
+ * no `dueDate` on any line, every amount falls in "current" and the report
372
+ * says nothing is overdue. So a selection where nothing carries a due date is
373
+ * refused outright, and a line with no `counterparty` is refused too — aging
374
+ * is per counterparty by definition. Lines that DO have a counterparty but no
375
+ * due date are payments and credits; they are reported as unapplied against
376
+ * that counterparty rather than folded into "current", where they would
377
+ * quietly cancel an overdue invoice.
378
+ */
379
+ function agingView(rows, options, money) {
380
+ const asOf = options.asOf;
381
+ if (asOf === undefined) {
382
+ throw new LedgerError("aging needs an explicit asOf date — reading it from the clock would make the same ledger produce a different report tomorrow, and an aging report is a document somebody files");
383
+ }
384
+ epochDay(asOf);
385
+ const accounts = options.filter.accounts ?? [];
386
+ if (accounts.length === 0) {
387
+ throw new LedgerError("aging needs the accounts to age (receivables, payables) — ageing every account in the ledger produces a table with no meaning");
388
+ }
389
+ if (rows.length === 0) {
390
+ throw new LedgerError(`no posted lines match accounts ${accounts.join(", ")} in the requested range, so there is nothing to age`);
391
+ }
392
+ const withoutCounterparty = rows.filter((r) => r.counterparty === null || r.counterparty === "");
393
+ if (withoutCounterparty.length > 0) {
394
+ const example = withoutCounterparty[0];
395
+ throw new LedgerError(`aging needs "counterparty" on every line and ${withoutCounterparty.length} of ${rows.length} selected lines do not have it (for example entry ${example.entry_id}, account ${example.account}) — post those lines again with counterparty set; a table that omits them is not an aging of this account`);
396
+ }
397
+ const withDueDate = rows.filter((r) => r.due_date !== null && r.due_date !== "");
398
+ if (withDueDate.length === 0) {
399
+ throw new LedgerError(`aging needs "dueDate" on the lines being aged and not one of the ${rows.length} selected lines has it — every amount would land in "current" and the report would say nothing is overdue, which is the partial answer this refuses to give`);
400
+ }
401
+ const byCounterparty = new Map();
402
+ for (const row of rows) {
403
+ const key = row.counterparty;
404
+ const entry = byCounterparty.get(key) ??
405
+ {
406
+ buckets: { current: 0n, "1-30": 0n, "31-60": 0n, "61-90": 0n, "90+": 0n },
407
+ unapplied: 0n,
408
+ };
409
+ const amount = BigInt(row.base_debit_minor) - BigInt(row.base_credit_minor);
410
+ if (row.due_date === null || row.due_date === "")
411
+ entry.unapplied += amount;
412
+ else
413
+ entry.buckets[bucketFor(row.due_date, asOf)] += amount;
414
+ byCounterparty.set(key, entry);
415
+ }
416
+ const out = [...byCounterparty.entries()]
417
+ .sort((a, b) => byString(a[0], b[0]))
418
+ .map(([counterparty, row]) => {
419
+ const total = BUCKETS.reduce((s, b) => s + row.buckets[b], 0n) + row.unapplied;
420
+ return {
421
+ counterparty,
422
+ current: money(row.buckets.current),
423
+ "1-30": money(row.buckets["1-30"]),
424
+ "31-60": money(row.buckets["31-60"]),
425
+ "61-90": money(row.buckets["61-90"]),
426
+ "90+": money(row.buckets["90+"]),
427
+ unapplied: money(row.unapplied),
428
+ totalMinor: total.toString(),
429
+ total: money(total),
430
+ };
431
+ });
432
+ const grand = BUCKETS.map((b) => [b, [...byCounterparty.values()].reduce((s, r) => s + r.buckets[b], 0n)]);
433
+ const unapplied = [...byCounterparty.values()].reduce((s, r) => s + r.unapplied, 0n);
434
+ return {
435
+ view: "aging",
436
+ rows: out.slice(0, options.limit),
437
+ totals: {
438
+ asOf,
439
+ ...Object.fromEntries(grand.map(([b, v]) => [b, money(v)])),
440
+ unapplied: money(unapplied),
441
+ currency: options.baseCurrency,
442
+ },
443
+ rowCount: out.length,
444
+ truncated: out.length > options.limit,
445
+ notes: rows.length === withDueDate.length
446
+ ? undefined
447
+ : [
448
+ `${rows.length - withDueDate.length} of ${rows.length} lines carry no dueDate and are reported as "unapplied" rather than aged; they are not counted as current`,
449
+ ],
450
+ };
451
+ }
452
+ // ---------------------------------------------------------------------------
453
+ // rendering
454
+ /** RFC 4180: quote when the field contains a comma, a quote or a newline. */
455
+ function csvField(value) {
456
+ return /[",\n\r]/.test(value) ? `"${value.split('"').join('""')}"` : value;
457
+ }
458
+ export function renderRows(rows, format) {
459
+ if (rows.length === 0)
460
+ return "";
461
+ // Column order comes from the first row's insertion order, which is written
462
+ // out literally above — never from a set union over every row, which would
463
+ // move the columns when a later row happens to carry an extra key.
464
+ const headers = Object.keys(rows[0]);
465
+ const cell = (row, key) => {
466
+ const value = row[key];
467
+ return value === null || value === undefined ? "" : String(value);
468
+ };
469
+ if (format === "csv") {
470
+ return [
471
+ headers.map(csvField).join(","),
472
+ ...rows.map((row) => headers.map((h) => csvField(cell(row, h))).join(",")),
473
+ ].join("\n");
474
+ }
475
+ return [
476
+ `| ${headers.join(" | ")} |`,
477
+ `| ${headers.map(() => "---").join(" | ")} |`,
478
+ ...rows.map((row) => `| ${headers.map((h) => cell(row, h).split("|").join("\\|")).join(" | ")} |`),
479
+ ].join("\n");
480
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Reconciliation: which of these rows are the same event.
3
+ *
4
+ * The rows on both sides are `@crewhaus/tool-money`'s `Transaction` — the
5
+ * shape `StatementParse` produces — imported rather than re-declared, because
6
+ * a second declaration of a row shape is a shape that drifts the first time
7
+ * somebody adds a field to one of them.
8
+ *
9
+ * THE RULE THAT MATTERS: a tolerance does NOT widen matching. A 100.00 and a
10
+ * 100.01 are not the same payment; they are two facts that disagree by a
11
+ * penny, and a report that pairs them has destroyed the only evidence that
12
+ * anything is wrong. So `matched` requires the amounts to be EQUAL, and the
13
+ * tolerance defines a separate `nearMisses` list — the pairs a person should
14
+ * look at. That is the opposite of what most reconcilers do and it is the
15
+ * reason this one is worth running unattended.
16
+ *
17
+ * Everything is deterministic. Candidates are enumerated in a fixed order
18
+ * (date, then id), the grouped pass walks subsets by ascending size and then
19
+ * lexicographically and takes the FIRST that sums exactly — so when several
20
+ * subsets of charges add up to the same payout, the same one is chosen on
21
+ * every rerun and the fee entry proposed alongside it does not move.
22
+ */
23
+ import type { Transaction } from "@crewhaus/tool-money";
24
+ export declare const MATCH_KINDS: readonly ["reference", "amount-date", "grouped", "grouped-with-fee"];
25
+ export type MatchKind = (typeof MATCH_KINDS)[number];
26
+ export declare const RECONCILE_LIMITS: {
27
+ readonly rowsPerSide: 20000;
28
+ readonly maxSubsetSize: 8;
29
+ /** Subsets examined for ONE right-hand row before the search gives up. */
30
+ readonly maxCombinations: 200000;
31
+ };
32
+ export type ProposalAccounts = {
33
+ readonly bankAccount: string;
34
+ readonly feeAccount: string;
35
+ readonly suspenseAccount: string;
36
+ };
37
+ export type ReconcileOptions = {
38
+ /** The near-miss radius in minor units. It does NOT widen `matched`. */
39
+ readonly toleranceMinor: bigint;
40
+ readonly windowDays: number;
41
+ readonly allowManyToOne: boolean;
42
+ readonly maxSubsetSize: number;
43
+ readonly maxCombinations: number;
44
+ /** Largest residue a grouped match may absorb as a fee. 0 turns the pass off. */
45
+ readonly feeToleranceMinor: bigint;
46
+ readonly requireReferenceMatch: boolean;
47
+ readonly exponent: number;
48
+ readonly currency: string;
49
+ readonly propose?: ProposalAccounts;
50
+ };
51
+ export type Match = {
52
+ readonly kind: MatchKind;
53
+ readonly leftIds: ReadonlyArray<string>;
54
+ readonly rightId: string;
55
+ readonly amountMinor: string;
56
+ readonly amount: string;
57
+ readonly dateDeltaDays: number;
58
+ readonly feeMinor: string;
59
+ readonly why: string;
60
+ };
61
+ export type NearMiss = {
62
+ /**
63
+ * How the pair was noticed. `reference` means both sides carry the same
64
+ * identifier and disagree about the amount, which is the single most
65
+ * interesting pair in a reconciliation and is reported whatever else
66
+ * happens to either row; `tolerance` means only the amounts are close.
67
+ */
68
+ readonly kind: "reference" | "tolerance";
69
+ readonly leftId: string;
70
+ readonly rightId: string;
71
+ readonly amountDeltaMinor: string;
72
+ readonly amountDelta: string;
73
+ readonly dateDeltaDays: number;
74
+ readonly why: string;
75
+ };
76
+ export type Unmatched = {
77
+ readonly id: string;
78
+ readonly date: string;
79
+ readonly description: string;
80
+ readonly amountMinor: string;
81
+ readonly amount: string;
82
+ readonly reference: string;
83
+ };
84
+ export type ProposedEntry = {
85
+ readonly date: string;
86
+ readonly memo: string;
87
+ readonly reference: string;
88
+ readonly lines: ReadonlyArray<{
89
+ readonly account: string;
90
+ readonly debitMinor?: string;
91
+ readonly creditMinor?: string;
92
+ }>;
93
+ readonly why: string;
94
+ };
95
+ export type ReconcileResult = {
96
+ readonly matched: ReadonlyArray<Match>;
97
+ readonly nearMisses: ReadonlyArray<NearMiss>;
98
+ readonly unmatchedLeft: ReadonlyArray<Unmatched>;
99
+ readonly unmatchedRight: ReadonlyArray<Unmatched>;
100
+ readonly proposedEntries: ReadonlyArray<ProposedEntry>;
101
+ readonly summary: {
102
+ readonly leftCount: number;
103
+ readonly rightCount: number;
104
+ readonly leftTotalMinor: string;
105
+ readonly rightTotalMinor: string;
106
+ readonly leftTotal: string;
107
+ readonly rightTotal: string;
108
+ readonly differenceMinor: string;
109
+ readonly difference: string;
110
+ readonly matchedCount: number;
111
+ readonly nearMissCount: number;
112
+ readonly currency: string;
113
+ };
114
+ /** Right-hand rows whose grouped search hit the combination cap. */
115
+ readonly groupingTruncated: ReadonlyArray<string>;
116
+ };
117
+ export declare function reconcile(leftRows: ReadonlyArray<Transaction>, rightRows: ReadonlyArray<Transaction>, options: ReconcileOptions): ReconcileResult;