tallyd 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +1493 -2
- package/dist/accounts/chart.d.ts +96 -0
- package/dist/accounts/chart.js +244 -0
- package/dist/accounts/index.d.ts +3 -0
- package/dist/accounts/index.js +3 -0
- package/dist/accounts/standard.d.ts +12 -0
- package/dist/accounts/standard.js +95 -0
- package/dist/accounts/types.d.ts +20 -0
- package/dist/accounts/types.js +40 -0
- package/dist/cli/args.d.ts +45 -0
- package/dist/cli/args.js +147 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +25 -0
- package/dist/cli/run.d.ts +34 -0
- package/dist/cli/run.js +1660 -0
- package/dist/dashboard/charts.d.ts +38 -0
- package/dist/dashboard/charts.js +173 -0
- package/dist/dashboard/index.d.ts +3 -0
- package/dist/dashboard/index.js +3 -0
- package/dist/dashboard/model.d.ts +137 -0
- package/dist/dashboard/model.js +190 -0
- package/dist/dashboard/render.d.ts +23 -0
- package/dist/dashboard/render.js +231 -0
- package/dist/dashboard/script.d.ts +28 -0
- package/dist/dashboard/script.js +704 -0
- package/dist/dashboard/styles.d.ts +14 -0
- package/dist/dashboard/styles.js +726 -0
- package/dist/demo/disposal.d.ts +80 -0
- package/dist/demo/disposal.js +215 -0
- package/dist/demo/evidence.d.ts +153 -0
- package/dist/demo/evidence.js +348 -0
- package/dist/demo/foreign.d.ts +29 -0
- package/dist/demo/foreign.js +113 -0
- package/dist/demo/foreignReport.d.ts +10 -0
- package/dist/demo/foreignReport.js +89 -0
- package/dist/demo/generator.d.ts +108 -0
- package/dist/demo/generator.js +445 -0
- package/dist/demo/group.d.ts +70 -0
- package/dist/demo/group.js +195 -0
- package/dist/demo/groupPeriods.d.ts +27 -0
- package/dist/demo/groupPeriods.js +125 -0
- package/dist/demo/groupReport.d.ts +13 -0
- package/dist/demo/groupReport.js +87 -0
- package/dist/demo/ingest.d.ts +6 -0
- package/dist/demo/ingest.js +61 -0
- package/dist/demo/main.d.ts +6 -0
- package/dist/demo/main.js +7 -0
- package/dist/demo/mainDisposal.d.ts +1 -0
- package/dist/demo/mainDisposal.js +2 -0
- package/dist/demo/mainEvidence.d.ts +8 -0
- package/dist/demo/mainEvidence.js +9 -0
- package/dist/demo/mainForeign.d.ts +6 -0
- package/dist/demo/mainForeign.js +7 -0
- package/dist/demo/mainGroup.d.ts +6 -0
- package/dist/demo/mainGroup.js +7 -0
- package/dist/demo/mainGroupPeriods.d.ts +6 -0
- package/dist/demo/mainGroupPeriods.js +7 -0
- package/dist/demo/mainIngest.d.ts +6 -0
- package/dist/demo/mainIngest.js +7 -0
- package/dist/demo/mainMidYear.d.ts +6 -0
- package/dist/demo/mainMidYear.js +7 -0
- package/dist/demo/mainReconcile.d.ts +6 -0
- package/dist/demo/mainReconcile.js +7 -0
- package/dist/demo/mainStatements.d.ts +6 -0
- package/dist/demo/mainStatements.js +7 -0
- package/dist/demo/midYear.d.ts +51 -0
- package/dist/demo/midYear.js +162 -0
- package/dist/demo/month.d.ts +13 -0
- package/dist/demo/month.js +142 -0
- package/dist/demo/random.d.ts +48 -0
- package/dist/demo/random.js +94 -0
- package/dist/demo/receivables.d.ts +36 -0
- package/dist/demo/receivables.js +104 -0
- package/dist/demo/reconcile.d.ts +20 -0
- package/dist/demo/reconcile.js +104 -0
- package/dist/demo/report.d.ts +6 -0
- package/dist/demo/report.js +26 -0
- package/dist/demo/statement.d.ts +13 -0
- package/dist/demo/statement.js +51 -0
- package/dist/demo/statements.d.ts +6 -0
- package/dist/demo/statements.js +29 -0
- package/dist/demo/supplierRun.d.ts +46 -0
- package/dist/demo/supplierRun.js +94 -0
- package/dist/demo/unrealisedEvidence.d.ts +51 -0
- package/dist/demo/unrealisedEvidence.js +183 -0
- package/dist/fx/average.d.ts +60 -0
- package/dist/fx/average.js +139 -0
- package/dist/fx/document.d.ts +60 -0
- package/dist/fx/document.js +205 -0
- package/dist/fx/exposure.d.ts +108 -0
- package/dist/fx/exposure.js +262 -0
- package/dist/fx/index.d.ts +8 -0
- package/dist/fx/index.js +8 -0
- package/dist/fx/rate.d.ts +77 -0
- package/dist/fx/rate.js +200 -0
- package/dist/fx/revaluation.d.ts +92 -0
- package/dist/fx/revaluation.js +197 -0
- package/dist/fx/settlement.d.ts +84 -0
- package/dist/fx/settlement.js +162 -0
- package/dist/fx/table.d.ts +120 -0
- package/dist/fx/table.js +271 -0
- package/dist/fx/translate.d.ts +126 -0
- package/dist/fx/translate.js +258 -0
- package/dist/group/accounts.d.ts +74 -0
- package/dist/group/accounts.js +179 -0
- package/dist/group/acquisition.d.ts +324 -0
- package/dist/group/acquisition.js +416 -0
- package/dist/group/aggregate.d.ts +143 -0
- package/dist/group/aggregate.js +288 -0
- package/dist/group/comparative.d.ts +102 -0
- package/dist/group/comparative.js +166 -0
- package/dist/group/consolidate.d.ts +196 -0
- package/dist/group/consolidate.js +635 -0
- package/dist/group/disposal.d.ts +181 -0
- package/dist/group/disposal.js +233 -0
- package/dist/group/document.d.ts +41 -0
- package/dist/group/document.js +326 -0
- package/dist/group/index.d.ts +12 -0
- package/dist/group/index.js +12 -0
- package/dist/group/intercompany.d.ts +136 -0
- package/dist/group/intercompany.js +238 -0
- package/dist/group/interest.d.ts +109 -0
- package/dist/group/interest.js +224 -0
- package/dist/group/movement.d.ts +206 -0
- package/dist/group/movement.js +432 -0
- package/dist/group/structure.d.ts +151 -0
- package/dist/group/structure.js +327 -0
- package/dist/group/timeline.d.ts +109 -0
- package/dist/group/timeline.js +169 -0
- package/dist/group/unrealised.d.ts +165 -0
- package/dist/group/unrealised.js +314 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ledger/close.d.ts +75 -0
- package/dist/ledger/close.js +142 -0
- package/dist/ledger/date.d.ts +48 -0
- package/dist/ledger/date.js +141 -0
- package/dist/ledger/entry.d.ts +150 -0
- package/dist/ledger/entry.js +264 -0
- package/dist/ledger/index.d.ts +6 -0
- package/dist/ledger/index.js +6 -0
- package/dist/ledger/ledger.d.ts +101 -0
- package/dist/ledger/ledger.js +308 -0
- package/dist/ledger/serialise.d.ts +68 -0
- package/dist/ledger/serialise.js +204 -0
- package/dist/ledger/trialBalance.d.ts +49 -0
- package/dist/ledger/trialBalance.js +103 -0
- package/dist/money/currency.d.ts +37 -0
- package/dist/money/currency.js +67 -0
- package/dist/money/index.d.ts +3 -0
- package/dist/money/index.js +3 -0
- package/dist/money/money.d.ts +106 -0
- package/dist/money/money.js +300 -0
- package/dist/money/rounding.d.ts +41 -0
- package/dist/money/rounding.js +117 -0
- package/dist/reconcile/accuracy.d.ts +65 -0
- package/dist/reconcile/accuracy.js +162 -0
- package/dist/reconcile/assignment.d.ts +83 -0
- package/dist/reconcile/assignment.js +326 -0
- package/dist/reconcile/bankView.d.ts +65 -0
- package/dist/reconcile/bankView.js +86 -0
- package/dist/reconcile/bridge.d.ts +76 -0
- package/dist/reconcile/bridge.js +129 -0
- package/dist/reconcile/candidates.d.ts +84 -0
- package/dist/reconcile/candidates.js +174 -0
- package/dist/reconcile/decisions.d.ts +116 -0
- package/dist/reconcile/decisions.js +168 -0
- package/dist/reconcile/index.d.ts +12 -0
- package/dist/reconcile/index.js +12 -0
- package/dist/reconcile/matcher.d.ts +89 -0
- package/dist/reconcile/matcher.js +306 -0
- package/dist/reconcile/memory.d.ts +120 -0
- package/dist/reconcile/memory.js +281 -0
- package/dist/reconcile/posting.d.ts +185 -0
- package/dist/reconcile/posting.js +481 -0
- package/dist/reconcile/scoring.d.ts +98 -0
- package/dist/reconcile/scoring.js +319 -0
- package/dist/reconcile/similarity.d.ts +88 -0
- package/dist/reconcile/similarity.js +272 -0
- package/dist/reconcile/subsetSum.d.ts +61 -0
- package/dist/reconcile/subsetSum.js +136 -0
- package/dist/reports/ageing.d.ts +75 -0
- package/dist/reports/ageing.js +149 -0
- package/dist/reports/balanceSheet.d.ts +57 -0
- package/dist/reports/balanceSheet.js +107 -0
- package/dist/reports/incomeStatement.d.ts +59 -0
- package/dist/reports/incomeStatement.js +132 -0
- package/dist/reports/index.d.ts +4 -0
- package/dist/reports/index.js +4 -0
- package/dist/reports/period.d.ts +37 -0
- package/dist/reports/period.js +78 -0
- package/dist/statement/amount.d.ts +55 -0
- package/dist/statement/amount.js +211 -0
- package/dist/statement/columns.d.ts +39 -0
- package/dist/statement/columns.js +221 -0
- package/dist/statement/csv.d.ts +50 -0
- package/dist/statement/csv.js +185 -0
- package/dist/statement/dates.d.ts +52 -0
- package/dist/statement/dates.js +204 -0
- package/dist/statement/duplicates.d.ts +51 -0
- package/dist/statement/duplicates.js +101 -0
- package/dist/statement/import.d.ts +52 -0
- package/dist/statement/import.js +172 -0
- package/dist/statement/index.d.ts +20 -0
- package/dist/statement/index.js +32 -0
- package/dist/statement/line.d.ts +60 -0
- package/dist/statement/line.js +73 -0
- package/dist/statement/ofx.d.ts +47 -0
- package/dist/statement/ofx.js +199 -0
- package/package.json +72 -4
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the reviewer already told us.
|
|
3
|
+
*
|
|
4
|
+
* Every month the same supplier arrives with a new reference. The bank writes
|
|
5
|
+
* `FPO ASHGROVE SUPPLIES 4471 BILL-3104` in March and `SO ASHGROVE SUPPLIES
|
|
6
|
+
* 8822 BILL-3391` in April; the books say `Payment — Ashgrove Supplies` both
|
|
7
|
+
* times. The description rule scores the two months identically, which means a
|
|
8
|
+
* pair a human confirmed in March lands in the review queue again in April, and
|
|
9
|
+
* again in May, for as long as anyone uses the tool.
|
|
10
|
+
*
|
|
11
|
+
* A confirmed match is a person stating a fact the matcher is trying and
|
|
12
|
+
* failing to infer: *these two counterparties are the same counterparty*. This
|
|
13
|
+
* remembers that fact.
|
|
14
|
+
*
|
|
15
|
+
* The key idea is what gets remembered. Not the transaction — that never
|
|
16
|
+
* repeats — but the **counterparty**, which is what is left of a description
|
|
17
|
+
* once the parts that change every month are removed. Anything carrying a digit
|
|
18
|
+
* is a reference, a terminal id or a date, so it goes; what remains is the
|
|
19
|
+
* name, and the name is stable on both sides.
|
|
20
|
+
*
|
|
21
|
+
* Three things are worth remembering, not one:
|
|
22
|
+
*
|
|
23
|
+
* - **A confirmation** is evidence for the pairing.
|
|
24
|
+
* - **A rejection** is evidence against, and matters more. Proposing a pair a
|
|
25
|
+
* reviewer has already refused twice is worse than proposing nothing: it
|
|
26
|
+
* teaches them the queue is not worth reading.
|
|
27
|
+
* - **A contradiction** — this bank name seen before, but always against a
|
|
28
|
+
* different ledger counterparty — is evidence against too, and it is the
|
|
29
|
+
* case that absence-of-memory would silently score as neutral.
|
|
30
|
+
*
|
|
31
|
+
* The store is immutable in the same way the ledger is: `learn` returns a new
|
|
32
|
+
* memory. A matcher run cannot mutate the memory it is reading from, which is
|
|
33
|
+
* what makes "reconcile with the memory as it stood before this run" a thing
|
|
34
|
+
* that can be said precisely.
|
|
35
|
+
*/
|
|
36
|
+
import { normaliseDescription } from "../statement/line.js";
|
|
37
|
+
/**
|
|
38
|
+
* The stable part of a description: the counterparty name.
|
|
39
|
+
*
|
|
40
|
+
* Tokens carrying a digit are dropped — they are references, terminal ids and
|
|
41
|
+
* dates, and they are precisely the parts that differ between two months of the
|
|
42
|
+
* same relationship. Very short tokens go too, since `TO`, `A` and `RE` carry
|
|
43
|
+
* no identity. What is left is sorted, so word order in the two systems does
|
|
44
|
+
* not matter: `ASHGROVE SUPPLIES PAYMENT` and `PAYMENT ASHGROVE SUPPLIES` are
|
|
45
|
+
* one counterparty written two ways.
|
|
46
|
+
*/
|
|
47
|
+
export function counterpartyKey(description) {
|
|
48
|
+
const normalised = normaliseDescription(description);
|
|
49
|
+
const tokens = normalised
|
|
50
|
+
.split(" ")
|
|
51
|
+
.filter((token) => token.length >= 3 && !/\d/.test(token));
|
|
52
|
+
return [...new Set(tokens)].sort().join(" ");
|
|
53
|
+
}
|
|
54
|
+
const UNKNOWN = Object.freeze({
|
|
55
|
+
kind: "unknown",
|
|
56
|
+
score: 0,
|
|
57
|
+
confirmed: 0,
|
|
58
|
+
rejected: 0,
|
|
59
|
+
detail: "nothing remembered about this counterparty",
|
|
60
|
+
});
|
|
61
|
+
const pairKey = (statementKey, bookKey) => `${statementKey}\u0000${bookKey}`;
|
|
62
|
+
const times = (count) => (count === 1 ? "once" : `${count} times`);
|
|
63
|
+
export class MemoryDocumentError extends Error {
|
|
64
|
+
constructor(message) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.name = "MemoryDocumentError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export class MatchMemory {
|
|
70
|
+
byPair;
|
|
71
|
+
/** Statement keys to the book keys they have been confirmed against. */
|
|
72
|
+
confirmedFor;
|
|
73
|
+
constructor(byPair, confirmedFor) {
|
|
74
|
+
this.byPair = byPair;
|
|
75
|
+
this.confirmedFor = confirmedFor;
|
|
76
|
+
}
|
|
77
|
+
static empty() {
|
|
78
|
+
return new MatchMemory(new Map(), new Map());
|
|
79
|
+
}
|
|
80
|
+
static from(decisions) {
|
|
81
|
+
let memory = MatchMemory.empty();
|
|
82
|
+
for (const decision of decisions)
|
|
83
|
+
memory = memory.learn(decision);
|
|
84
|
+
return memory;
|
|
85
|
+
}
|
|
86
|
+
get size() {
|
|
87
|
+
return this.byPair.size;
|
|
88
|
+
}
|
|
89
|
+
/** Every entry, in a stable order: most confirmed first, then by key. */
|
|
90
|
+
get entries() {
|
|
91
|
+
return Object.freeze([...this.byPair.values()].sort((a, b) => b.confirmed - a.confirmed ||
|
|
92
|
+
b.rejected - a.rejected ||
|
|
93
|
+
(a.statementKey < b.statementKey ? -1 : a.statementKey > b.statementKey ? 1 : 0) ||
|
|
94
|
+
(a.bookKey < b.bookKey ? -1 : a.bookKey > b.bookKey ? 1 : 0)));
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Record a decision, returning the memory that results.
|
|
98
|
+
*
|
|
99
|
+
* A decision about a description with no stable part — a bank line reading
|
|
100
|
+
* only `4471 8822` — is dropped rather than stored under an empty key, which
|
|
101
|
+
* would collide with every other nameless line.
|
|
102
|
+
*/
|
|
103
|
+
learn(decision) {
|
|
104
|
+
const statementKey = counterpartyKey(decision.statementDescription);
|
|
105
|
+
const bookKey = counterpartyKey(decision.bookDescription);
|
|
106
|
+
if (statementKey === "" || bookKey === "")
|
|
107
|
+
return this;
|
|
108
|
+
const key = pairKey(statementKey, bookKey);
|
|
109
|
+
const existing = this.byPair.get(key);
|
|
110
|
+
const entry = Object.freeze({
|
|
111
|
+
statementKey,
|
|
112
|
+
bookKey,
|
|
113
|
+
confirmed: (existing?.confirmed ?? 0) + (decision.accepted ? 1 : 0),
|
|
114
|
+
rejected: (existing?.rejected ?? 0) + (decision.accepted ? 0 : 1),
|
|
115
|
+
lastSeen: existing === undefined || decision.on > existing.lastSeen ? decision.on : existing.lastSeen,
|
|
116
|
+
});
|
|
117
|
+
const byPair = new Map(this.byPair);
|
|
118
|
+
byPair.set(key, entry);
|
|
119
|
+
const confirmedFor = new Map([...this.confirmedFor].map(([k, v]) => [k, new Set(v)]));
|
|
120
|
+
if (decision.accepted) {
|
|
121
|
+
const set = confirmedFor.get(statementKey) ?? new Set();
|
|
122
|
+
set.add(bookKey);
|
|
123
|
+
confirmedFor.set(statementKey, set);
|
|
124
|
+
}
|
|
125
|
+
return new MatchMemory(byPair, confirmedFor);
|
|
126
|
+
}
|
|
127
|
+
learnAll(decisions) {
|
|
128
|
+
let memory = this;
|
|
129
|
+
for (const decision of decisions)
|
|
130
|
+
memory = memory.learn(decision);
|
|
131
|
+
return memory;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* What is remembered about pairing these two descriptions?
|
|
135
|
+
*
|
|
136
|
+
* The order of the checks is the order of how much they should be believed.
|
|
137
|
+
* A rejection outweighs a confirmation of the same pair: if a reviewer has
|
|
138
|
+
* both accepted and refused this pairing, the refusal is the more recent
|
|
139
|
+
* correction of the two more often than not, and being wrong in the cautious
|
|
140
|
+
* direction costs a minute rather than an afternoon.
|
|
141
|
+
*/
|
|
142
|
+
recall(statementDescription, bookDescription) {
|
|
143
|
+
const statementKey = counterpartyKey(statementDescription);
|
|
144
|
+
const bookKey = counterpartyKey(bookDescription);
|
|
145
|
+
if (statementKey === "" || bookKey === "")
|
|
146
|
+
return UNKNOWN;
|
|
147
|
+
const entry = this.byPair.get(pairKey(statementKey, bookKey));
|
|
148
|
+
if (entry !== undefined && entry.rejected > 0) {
|
|
149
|
+
return Object.freeze({
|
|
150
|
+
kind: "rejected",
|
|
151
|
+
score: 0,
|
|
152
|
+
confirmed: entry.confirmed,
|
|
153
|
+
rejected: entry.rejected,
|
|
154
|
+
detail: entry.confirmed > 0
|
|
155
|
+
? `rejected ${times(entry.rejected)} before, accepted ${times(entry.confirmed)}`
|
|
156
|
+
: `this pairing was rejected ${times(entry.rejected)} before`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
if (entry !== undefined && entry.confirmed > 0) {
|
|
160
|
+
return Object.freeze({
|
|
161
|
+
kind: "confirmed",
|
|
162
|
+
score: 1,
|
|
163
|
+
confirmed: entry.confirmed,
|
|
164
|
+
rejected: 0,
|
|
165
|
+
detail: `confirmed ${times(entry.confirmed)} before, most recently ${entry.lastSeen}`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
// Seen this bank name, but always against somebody else. Absence of a
|
|
169
|
+
// matching entry would score as neutral; this is not neutral.
|
|
170
|
+
const known = this.confirmedFor.get(statementKey);
|
|
171
|
+
if (known !== undefined && known.size > 0 && !known.has(bookKey)) {
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
kind: "contradicted",
|
|
174
|
+
score: 0,
|
|
175
|
+
confirmed: 0,
|
|
176
|
+
rejected: 0,
|
|
177
|
+
detail: `"${statementKey}" has only ever been confirmed against something else`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return UNKNOWN;
|
|
181
|
+
}
|
|
182
|
+
/** Forget a pairing entirely — the undo for a decision made in error. */
|
|
183
|
+
forget(statementDescription, bookDescription) {
|
|
184
|
+
const statementKey = counterpartyKey(statementDescription);
|
|
185
|
+
const bookKey = counterpartyKey(bookDescription);
|
|
186
|
+
const key = pairKey(statementKey, bookKey);
|
|
187
|
+
if (!this.byPair.has(key))
|
|
188
|
+
return this;
|
|
189
|
+
const byPair = new Map(this.byPair);
|
|
190
|
+
byPair.delete(key);
|
|
191
|
+
const confirmedFor = new Map();
|
|
192
|
+
for (const entry of byPair.values()) {
|
|
193
|
+
if (entry.confirmed === 0)
|
|
194
|
+
continue;
|
|
195
|
+
const set = confirmedFor.get(entry.statementKey) ?? new Set();
|
|
196
|
+
set.add(entry.bookKey);
|
|
197
|
+
confirmedFor.set(entry.statementKey, set);
|
|
198
|
+
}
|
|
199
|
+
return new MatchMemory(byPair, confirmedFor);
|
|
200
|
+
}
|
|
201
|
+
toDocument() {
|
|
202
|
+
return Object.freeze({ version: 1, entries: this.entries });
|
|
203
|
+
}
|
|
204
|
+
static fromDocument(document) {
|
|
205
|
+
if (typeof document !== "object" || document === null) {
|
|
206
|
+
throw new MemoryDocumentError("A memory document must be an object");
|
|
207
|
+
}
|
|
208
|
+
const raw = document;
|
|
209
|
+
if (raw.version !== 1) {
|
|
210
|
+
throw new MemoryDocumentError(`Unsupported memory version: ${String(raw.version)}`);
|
|
211
|
+
}
|
|
212
|
+
if (!Array.isArray(raw.entries)) {
|
|
213
|
+
throw new MemoryDocumentError("A memory document needs an entries array");
|
|
214
|
+
}
|
|
215
|
+
const byPair = new Map();
|
|
216
|
+
const confirmedFor = new Map();
|
|
217
|
+
raw.entries.forEach((value, index) => {
|
|
218
|
+
const entry = value;
|
|
219
|
+
if (typeof entry.statementKey !== "string" || typeof entry.bookKey !== "string") {
|
|
220
|
+
throw new MemoryDocumentError(`Entry ${index} needs a statementKey and a bookKey`);
|
|
221
|
+
}
|
|
222
|
+
const confirmed = entry.confirmed ?? 0;
|
|
223
|
+
const rejected = entry.rejected ?? 0;
|
|
224
|
+
if (!Number.isInteger(confirmed) || confirmed < 0 || !Number.isInteger(rejected) || rejected < 0) {
|
|
225
|
+
throw new MemoryDocumentError(`Entry ${index} has a nonsensical count`);
|
|
226
|
+
}
|
|
227
|
+
if (confirmed === 0 && rejected === 0) {
|
|
228
|
+
throw new MemoryDocumentError(`Entry ${index} records neither a confirmation nor a rejection`);
|
|
229
|
+
}
|
|
230
|
+
if (typeof entry.lastSeen !== "string") {
|
|
231
|
+
throw new MemoryDocumentError(`Entry ${index} needs a lastSeen date`);
|
|
232
|
+
}
|
|
233
|
+
byPair.set(pairKey(entry.statementKey, entry.bookKey), Object.freeze({
|
|
234
|
+
statementKey: entry.statementKey,
|
|
235
|
+
bookKey: entry.bookKey,
|
|
236
|
+
confirmed,
|
|
237
|
+
rejected,
|
|
238
|
+
lastSeen: entry.lastSeen,
|
|
239
|
+
}));
|
|
240
|
+
if (confirmed > 0) {
|
|
241
|
+
const set = confirmedFor.get(entry.statementKey) ?? new Set();
|
|
242
|
+
set.add(entry.bookKey);
|
|
243
|
+
confirmedFor.set(entry.statementKey, set);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
return new MatchMemory(byPair, confirmedFor);
|
|
247
|
+
}
|
|
248
|
+
toJson(indent = 2) {
|
|
249
|
+
return `${JSON.stringify(this.toDocument(), null, indent)}\n`;
|
|
250
|
+
}
|
|
251
|
+
static fromJson(text) {
|
|
252
|
+
let parsed;
|
|
253
|
+
try {
|
|
254
|
+
parsed = JSON.parse(text);
|
|
255
|
+
}
|
|
256
|
+
catch (error) {
|
|
257
|
+
throw new MemoryDocumentError(`Not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
258
|
+
}
|
|
259
|
+
return MatchMemory.fromDocument(parsed);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
/** What the memory holds, as something a person can read down. */
|
|
263
|
+
export function renderMemory(memory) {
|
|
264
|
+
if (memory.size === 0)
|
|
265
|
+
return "Nothing remembered yet.";
|
|
266
|
+
const rows = memory.entries.map((entry) => [
|
|
267
|
+
entry.statementKey,
|
|
268
|
+
entry.bookKey,
|
|
269
|
+
`${entry.confirmed}/${entry.rejected}`,
|
|
270
|
+
entry.lastSeen,
|
|
271
|
+
]);
|
|
272
|
+
const header = ["bank", "ledger", "yes/no", "last seen"];
|
|
273
|
+
const widths = header.map((cell, column) => Math.max(cell.length, ...rows.map((row) => row[column].length)));
|
|
274
|
+
const format = (cells) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd();
|
|
275
|
+
return [
|
|
276
|
+
`${memory.size} remembered ${memory.size === 1 ? "pairing" : "pairings"}`,
|
|
277
|
+
"",
|
|
278
|
+
format(header),
|
|
279
|
+
...rows.map(format),
|
|
280
|
+
].join("\n");
|
|
281
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a reconciliation implies the books are missing.
|
|
3
|
+
*
|
|
4
|
+
* A reconciliation ends with statement lines the ledger has never heard of:
|
|
5
|
+
* the bank charge nobody entered, the interest, the direct debit that was set
|
|
6
|
+
* up two years ago, the card processor's fee. They are not matching failures —
|
|
7
|
+
* there is genuinely nothing to match them against — and the reconciliation
|
|
8
|
+
* will not balance until somebody books them.
|
|
9
|
+
*
|
|
10
|
+
* "Somebody books them" is where the cycle currently breaks. The matcher knows
|
|
11
|
+
* exactly which lines are missing and says nothing about what to do with them,
|
|
12
|
+
* so a person retypes each one into the ledger, guesses the account, and the
|
|
13
|
+
* guess is recorded nowhere. This module makes the guess explicit, attributable
|
|
14
|
+
* to a rule, and repeatable.
|
|
15
|
+
*
|
|
16
|
+
* ## Three deliberate refusals
|
|
17
|
+
*
|
|
18
|
+
* **It proposes; it does not post.** Every proposal carries the statement line
|
|
19
|
+
* it came from, the rule that classified it, and a balanced entry. Applying
|
|
20
|
+
* them is a separate step a caller has to ask for.
|
|
21
|
+
*
|
|
22
|
+
* **It never invents an account.** A line no rule matches is reported as
|
|
23
|
+
* unclassified. Sweeping the remainder into a suspense account the chart does
|
|
24
|
+
* not have is how a chart of accounts rots: the account appears, everything
|
|
25
|
+
* awkward goes in it, and nobody ever empties it. A caller who wants that
|
|
26
|
+
* behaviour names an account that already exists and takes responsibility for
|
|
27
|
+
* it.
|
|
28
|
+
*
|
|
29
|
+
* **Posting the same statement twice is a no-op.** Entry ids are derived from
|
|
30
|
+
* the statement line's fingerprint — its date, amount and normalised
|
|
31
|
+
* description — so the same line always implies the same entry id, and an id
|
|
32
|
+
* already in the ledger is skipped with a reason rather than posted again.
|
|
33
|
+
* Overlapping statement exports are the normal case, not an edge case.
|
|
34
|
+
*/
|
|
35
|
+
import { Money } from "../money/money.js";
|
|
36
|
+
import type { ChartOfAccounts } from "../accounts/chart.js";
|
|
37
|
+
import type { Ledger } from "../ledger/ledger.js";
|
|
38
|
+
import { JournalEntry } from "../ledger/entry.js";
|
|
39
|
+
import type { StatementLine } from "../statement/line.js";
|
|
40
|
+
import type { ReconciliationResult } from "./matcher.js";
|
|
41
|
+
import type { MatchMemory } from "./memory.js";
|
|
42
|
+
/** Which way the money went, from the account holder's point of view. */
|
|
43
|
+
export type Direction = "in" | "out" | "either";
|
|
44
|
+
/**
|
|
45
|
+
* A test against a statement line's description.
|
|
46
|
+
*
|
|
47
|
+
* Matching is done on the *normalised* description — uppercased, with card
|
|
48
|
+
* scheme noise, references and dates stripped — because that is the form in
|
|
49
|
+
* which two months of the same standing order look alike.
|
|
50
|
+
*/
|
|
51
|
+
export interface RuleMatch {
|
|
52
|
+
/** Every one of these must appear. */
|
|
53
|
+
readonly all?: readonly string[];
|
|
54
|
+
/** At least one of these must appear. */
|
|
55
|
+
readonly any?: readonly string[];
|
|
56
|
+
/** None of these may appear. Checked last, and it wins. */
|
|
57
|
+
readonly none?: readonly string[];
|
|
58
|
+
/** Applied to the normalised description, case-insensitively. */
|
|
59
|
+
readonly regex?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* What to do with a line that matches.
|
|
63
|
+
*
|
|
64
|
+
* `book` is the usual case. `skip` exists because some statement lines are not
|
|
65
|
+
* transactions at all: an opening balance line is the bank telling you where it
|
|
66
|
+
* thinks you started, and booking it would double-count the whole period.
|
|
67
|
+
*/
|
|
68
|
+
export type RuleAction = "book" | "skip";
|
|
69
|
+
export interface PostingRule {
|
|
70
|
+
readonly id: string;
|
|
71
|
+
/** What a person reading the output should understand this rule to mean. */
|
|
72
|
+
readonly describe: string;
|
|
73
|
+
readonly match: RuleMatch;
|
|
74
|
+
/** Restrict to money in or money out. Interest earned and interest paid are
|
|
75
|
+
* the same word and different accounts. */
|
|
76
|
+
readonly direction?: Direction;
|
|
77
|
+
readonly action?: RuleAction;
|
|
78
|
+
/** The other side of the entry. Required when the action is `book`. */
|
|
79
|
+
readonly account?: string;
|
|
80
|
+
/** Narration for the entry. Defaults to the statement description. */
|
|
81
|
+
readonly narration?: string;
|
|
82
|
+
}
|
|
83
|
+
export declare class PostingRuleError extends Error {
|
|
84
|
+
constructor(message: string);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The rules that come in the box, against the standard chart.
|
|
88
|
+
*
|
|
89
|
+
* Ordered, and the order is load-bearing: `INTEREST` outbound is a cost of
|
|
90
|
+
* borrowing and `INTEREST` inbound is income, so the two interest rules must
|
|
91
|
+
* be narrower than the general charges rule that follows them. Opening balance
|
|
92
|
+
* lines are skipped first, before anything can classify them as a receipt.
|
|
93
|
+
*
|
|
94
|
+
* This is a starting point and not a claim about anybody's business. A chart
|
|
95
|
+
* that is not this chart needs its own rules; that is what `--rules` is for.
|
|
96
|
+
*/
|
|
97
|
+
export declare function standardRules(): readonly PostingRule[];
|
|
98
|
+
/** Why a proposal came out the way it did. */
|
|
99
|
+
export type ProposalOutcome = "book" | "skip" | "unclassified" | "already-booked";
|
|
100
|
+
export interface Proposal {
|
|
101
|
+
readonly line: StatementLine;
|
|
102
|
+
readonly outcome: ProposalOutcome;
|
|
103
|
+
/** The rule that fired, when one did. */
|
|
104
|
+
readonly rule: PostingRule | null;
|
|
105
|
+
/** The entry to post. Present only when the outcome is `book`. */
|
|
106
|
+
readonly entry: JournalEntry | null;
|
|
107
|
+
/** The account the other side lands in, when there is one. */
|
|
108
|
+
readonly account: string | null;
|
|
109
|
+
/** One line a person can read. */
|
|
110
|
+
readonly reason: string;
|
|
111
|
+
}
|
|
112
|
+
export interface ProposalOptions {
|
|
113
|
+
/** The account the statement belongs to. */
|
|
114
|
+
readonly account: string;
|
|
115
|
+
readonly rules?: readonly PostingRule[];
|
|
116
|
+
/**
|
|
117
|
+
* Where lines no rule matched should go. Must already exist in the chart;
|
|
118
|
+
* nothing here creates an account. Omitted means they stay unclassified.
|
|
119
|
+
*/
|
|
120
|
+
readonly suspenseAccount?: string;
|
|
121
|
+
/** Prefix for generated entry ids. */
|
|
122
|
+
readonly idPrefix?: string;
|
|
123
|
+
/**
|
|
124
|
+
* `1` when a debit to the account is money in — an asset, the normal case —
|
|
125
|
+
* and `-1` for a liability such as a credit card. Matches `bankView`.
|
|
126
|
+
*/
|
|
127
|
+
readonly inflowSign?: 1 | -1;
|
|
128
|
+
/** Skip lines whose implied entry the ledger already holds. */
|
|
129
|
+
readonly ledger?: Ledger;
|
|
130
|
+
readonly chart?: ChartOfAccounts;
|
|
131
|
+
/** Tags to put on every entry, so an import can be found again. */
|
|
132
|
+
readonly tags?: readonly string[];
|
|
133
|
+
}
|
|
134
|
+
/** The entry id a statement line implies, whoever asks and however often. */
|
|
135
|
+
export declare function impliedEntryId(line: StatementLine, prefix?: string): string;
|
|
136
|
+
/**
|
|
137
|
+
* What the ledger is missing, one proposal per statement line handed in.
|
|
138
|
+
*
|
|
139
|
+
* Callers pass the lines that reconciliation could not explain. Every line
|
|
140
|
+
* gets a proposal, including the ones that come to nothing: a queue of
|
|
141
|
+
* eleven lines that produced four entries should say so, and say why the
|
|
142
|
+
* other seven did not.
|
|
143
|
+
*/
|
|
144
|
+
export declare function proposeEntries(lines: readonly StatementLine[], options: ProposalOptions): readonly Proposal[];
|
|
145
|
+
/** Post everything a proposal set says to post, in the order it was proposed. */
|
|
146
|
+
export declare function applyProposals(ledger: Ledger, proposals: readonly Proposal[]): Ledger;
|
|
147
|
+
export interface ProposalSummary {
|
|
148
|
+
readonly total: number;
|
|
149
|
+
readonly booked: number;
|
|
150
|
+
readonly skipped: number;
|
|
151
|
+
readonly unclassified: number;
|
|
152
|
+
readonly alreadyBooked: number;
|
|
153
|
+
/** Net effect on the bank account, in the statement's own direction. */
|
|
154
|
+
readonly net: Money;
|
|
155
|
+
/** Booked totals per contra account, most material first. */
|
|
156
|
+
readonly byAccount: readonly {
|
|
157
|
+
readonly account: string;
|
|
158
|
+
readonly amount: Money;
|
|
159
|
+
readonly count: number;
|
|
160
|
+
}[];
|
|
161
|
+
}
|
|
162
|
+
export declare function summariseProposals(proposals: readonly Proposal[], currency: Money["currency"] | string): ProposalSummary;
|
|
163
|
+
/** Read a rules file. */
|
|
164
|
+
export declare function parseRules(text: string): readonly PostingRule[];
|
|
165
|
+
/** The proposals, as something a person can read down. */
|
|
166
|
+
export declare function renderProposals(proposals: readonly Proposal[], summary: ProposalSummary): string;
|
|
167
|
+
/**
|
|
168
|
+
* Which statement lines still need booking.
|
|
169
|
+
*
|
|
170
|
+
* The unmatched ones, plainly — nothing in the books corresponds to them. And
|
|
171
|
+
* then the ones sitting under a suggestion the reviewer has already refused:
|
|
172
|
+
* a rejected pairing is a person saying this line does *not* belong to that
|
|
173
|
+
* entry, which leaves the line with no counterpart at all.
|
|
174
|
+
*
|
|
175
|
+
* A suggestion nobody has decided is left alone. The matcher believes there is
|
|
176
|
+
* a ledger entry behind it, and booking a second one would double-count the
|
|
177
|
+
* transaction. Undecided means unproposed, which is the conservative reading
|
|
178
|
+
* and the right one: a missed entry is a reconciliation that does not balance,
|
|
179
|
+
* and a duplicated entry is a reconciliation that balances and is wrong.
|
|
180
|
+
*
|
|
181
|
+
* "Refused" means every description pair in the suggestion recalls as rejected.
|
|
182
|
+
* A batch where the reviewer refused one of four suppliers is still, in part,
|
|
183
|
+
* something the books know about.
|
|
184
|
+
*/
|
|
185
|
+
export declare function linesNeedingEntries(result: ReconciliationResult, memory?: MatchMemory): readonly StatementLine[];
|