@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.
- package/README.md +243 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +888 -0
- package/dist/lib/amount.d.ts +73 -0
- package/dist/lib/amount.js +204 -0
- package/dist/lib/db.d.ts +171 -0
- package/dist/lib/db.js +326 -0
- package/dist/lib/invoice.d.ts +222 -0
- package/dist/lib/invoice.js +500 -0
- package/dist/lib/post.d.ts +129 -0
- package/dist/lib/post.js +440 -0
- package/dist/lib/query.d.ts +62 -0
- package/dist/lib/query.js +480 -0
- package/dist/lib/reconcile.d.ts +117 -0
- package/dist/lib/reconcile.js +335 -0
- package/dist/paths.d.ts +42 -0
- package/dist/paths.js +158 -0
- package/package.json +48 -0
|
@@ -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;
|