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.
Files changed (213) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1493 -2
  3. package/dist/accounts/chart.d.ts +96 -0
  4. package/dist/accounts/chart.js +244 -0
  5. package/dist/accounts/index.d.ts +3 -0
  6. package/dist/accounts/index.js +3 -0
  7. package/dist/accounts/standard.d.ts +12 -0
  8. package/dist/accounts/standard.js +95 -0
  9. package/dist/accounts/types.d.ts +20 -0
  10. package/dist/accounts/types.js +40 -0
  11. package/dist/cli/args.d.ts +45 -0
  12. package/dist/cli/args.js +147 -0
  13. package/dist/cli/index.d.ts +2 -0
  14. package/dist/cli/index.js +2 -0
  15. package/dist/cli/main.d.ts +6 -0
  16. package/dist/cli/main.js +25 -0
  17. package/dist/cli/run.d.ts +34 -0
  18. package/dist/cli/run.js +1660 -0
  19. package/dist/dashboard/charts.d.ts +38 -0
  20. package/dist/dashboard/charts.js +173 -0
  21. package/dist/dashboard/index.d.ts +3 -0
  22. package/dist/dashboard/index.js +3 -0
  23. package/dist/dashboard/model.d.ts +137 -0
  24. package/dist/dashboard/model.js +190 -0
  25. package/dist/dashboard/render.d.ts +23 -0
  26. package/dist/dashboard/render.js +231 -0
  27. package/dist/dashboard/script.d.ts +28 -0
  28. package/dist/dashboard/script.js +704 -0
  29. package/dist/dashboard/styles.d.ts +14 -0
  30. package/dist/dashboard/styles.js +726 -0
  31. package/dist/demo/disposal.d.ts +80 -0
  32. package/dist/demo/disposal.js +215 -0
  33. package/dist/demo/evidence.d.ts +153 -0
  34. package/dist/demo/evidence.js +348 -0
  35. package/dist/demo/foreign.d.ts +29 -0
  36. package/dist/demo/foreign.js +113 -0
  37. package/dist/demo/foreignReport.d.ts +10 -0
  38. package/dist/demo/foreignReport.js +89 -0
  39. package/dist/demo/generator.d.ts +108 -0
  40. package/dist/demo/generator.js +445 -0
  41. package/dist/demo/group.d.ts +70 -0
  42. package/dist/demo/group.js +195 -0
  43. package/dist/demo/groupPeriods.d.ts +27 -0
  44. package/dist/demo/groupPeriods.js +125 -0
  45. package/dist/demo/groupReport.d.ts +13 -0
  46. package/dist/demo/groupReport.js +87 -0
  47. package/dist/demo/ingest.d.ts +6 -0
  48. package/dist/demo/ingest.js +61 -0
  49. package/dist/demo/main.d.ts +6 -0
  50. package/dist/demo/main.js +7 -0
  51. package/dist/demo/mainDisposal.d.ts +1 -0
  52. package/dist/demo/mainDisposal.js +2 -0
  53. package/dist/demo/mainEvidence.d.ts +8 -0
  54. package/dist/demo/mainEvidence.js +9 -0
  55. package/dist/demo/mainForeign.d.ts +6 -0
  56. package/dist/demo/mainForeign.js +7 -0
  57. package/dist/demo/mainGroup.d.ts +6 -0
  58. package/dist/demo/mainGroup.js +7 -0
  59. package/dist/demo/mainGroupPeriods.d.ts +6 -0
  60. package/dist/demo/mainGroupPeriods.js +7 -0
  61. package/dist/demo/mainIngest.d.ts +6 -0
  62. package/dist/demo/mainIngest.js +7 -0
  63. package/dist/demo/mainMidYear.d.ts +6 -0
  64. package/dist/demo/mainMidYear.js +7 -0
  65. package/dist/demo/mainReconcile.d.ts +6 -0
  66. package/dist/demo/mainReconcile.js +7 -0
  67. package/dist/demo/mainStatements.d.ts +6 -0
  68. package/dist/demo/mainStatements.js +7 -0
  69. package/dist/demo/midYear.d.ts +51 -0
  70. package/dist/demo/midYear.js +162 -0
  71. package/dist/demo/month.d.ts +13 -0
  72. package/dist/demo/month.js +142 -0
  73. package/dist/demo/random.d.ts +48 -0
  74. package/dist/demo/random.js +94 -0
  75. package/dist/demo/receivables.d.ts +36 -0
  76. package/dist/demo/receivables.js +104 -0
  77. package/dist/demo/reconcile.d.ts +20 -0
  78. package/dist/demo/reconcile.js +104 -0
  79. package/dist/demo/report.d.ts +6 -0
  80. package/dist/demo/report.js +26 -0
  81. package/dist/demo/statement.d.ts +13 -0
  82. package/dist/demo/statement.js +51 -0
  83. package/dist/demo/statements.d.ts +6 -0
  84. package/dist/demo/statements.js +29 -0
  85. package/dist/demo/supplierRun.d.ts +46 -0
  86. package/dist/demo/supplierRun.js +94 -0
  87. package/dist/demo/unrealisedEvidence.d.ts +51 -0
  88. package/dist/demo/unrealisedEvidence.js +183 -0
  89. package/dist/fx/average.d.ts +60 -0
  90. package/dist/fx/average.js +139 -0
  91. package/dist/fx/document.d.ts +60 -0
  92. package/dist/fx/document.js +205 -0
  93. package/dist/fx/exposure.d.ts +108 -0
  94. package/dist/fx/exposure.js +262 -0
  95. package/dist/fx/index.d.ts +8 -0
  96. package/dist/fx/index.js +8 -0
  97. package/dist/fx/rate.d.ts +77 -0
  98. package/dist/fx/rate.js +200 -0
  99. package/dist/fx/revaluation.d.ts +92 -0
  100. package/dist/fx/revaluation.js +197 -0
  101. package/dist/fx/settlement.d.ts +84 -0
  102. package/dist/fx/settlement.js +162 -0
  103. package/dist/fx/table.d.ts +120 -0
  104. package/dist/fx/table.js +271 -0
  105. package/dist/fx/translate.d.ts +126 -0
  106. package/dist/fx/translate.js +258 -0
  107. package/dist/group/accounts.d.ts +74 -0
  108. package/dist/group/accounts.js +179 -0
  109. package/dist/group/acquisition.d.ts +324 -0
  110. package/dist/group/acquisition.js +416 -0
  111. package/dist/group/aggregate.d.ts +143 -0
  112. package/dist/group/aggregate.js +288 -0
  113. package/dist/group/comparative.d.ts +102 -0
  114. package/dist/group/comparative.js +166 -0
  115. package/dist/group/consolidate.d.ts +196 -0
  116. package/dist/group/consolidate.js +635 -0
  117. package/dist/group/disposal.d.ts +181 -0
  118. package/dist/group/disposal.js +233 -0
  119. package/dist/group/document.d.ts +41 -0
  120. package/dist/group/document.js +326 -0
  121. package/dist/group/index.d.ts +12 -0
  122. package/dist/group/index.js +12 -0
  123. package/dist/group/intercompany.d.ts +136 -0
  124. package/dist/group/intercompany.js +238 -0
  125. package/dist/group/interest.d.ts +109 -0
  126. package/dist/group/interest.js +224 -0
  127. package/dist/group/movement.d.ts +206 -0
  128. package/dist/group/movement.js +432 -0
  129. package/dist/group/structure.d.ts +151 -0
  130. package/dist/group/structure.js +327 -0
  131. package/dist/group/timeline.d.ts +109 -0
  132. package/dist/group/timeline.js +169 -0
  133. package/dist/group/unrealised.d.ts +165 -0
  134. package/dist/group/unrealised.js +314 -0
  135. package/dist/index.d.ts +10 -0
  136. package/dist/index.js +10 -0
  137. package/dist/ledger/close.d.ts +75 -0
  138. package/dist/ledger/close.js +142 -0
  139. package/dist/ledger/date.d.ts +48 -0
  140. package/dist/ledger/date.js +141 -0
  141. package/dist/ledger/entry.d.ts +150 -0
  142. package/dist/ledger/entry.js +264 -0
  143. package/dist/ledger/index.d.ts +6 -0
  144. package/dist/ledger/index.js +6 -0
  145. package/dist/ledger/ledger.d.ts +101 -0
  146. package/dist/ledger/ledger.js +308 -0
  147. package/dist/ledger/serialise.d.ts +68 -0
  148. package/dist/ledger/serialise.js +204 -0
  149. package/dist/ledger/trialBalance.d.ts +49 -0
  150. package/dist/ledger/trialBalance.js +103 -0
  151. package/dist/money/currency.d.ts +37 -0
  152. package/dist/money/currency.js +67 -0
  153. package/dist/money/index.d.ts +3 -0
  154. package/dist/money/index.js +3 -0
  155. package/dist/money/money.d.ts +106 -0
  156. package/dist/money/money.js +300 -0
  157. package/dist/money/rounding.d.ts +41 -0
  158. package/dist/money/rounding.js +117 -0
  159. package/dist/reconcile/accuracy.d.ts +65 -0
  160. package/dist/reconcile/accuracy.js +162 -0
  161. package/dist/reconcile/assignment.d.ts +83 -0
  162. package/dist/reconcile/assignment.js +326 -0
  163. package/dist/reconcile/bankView.d.ts +65 -0
  164. package/dist/reconcile/bankView.js +86 -0
  165. package/dist/reconcile/bridge.d.ts +76 -0
  166. package/dist/reconcile/bridge.js +129 -0
  167. package/dist/reconcile/candidates.d.ts +84 -0
  168. package/dist/reconcile/candidates.js +174 -0
  169. package/dist/reconcile/decisions.d.ts +116 -0
  170. package/dist/reconcile/decisions.js +168 -0
  171. package/dist/reconcile/index.d.ts +12 -0
  172. package/dist/reconcile/index.js +12 -0
  173. package/dist/reconcile/matcher.d.ts +89 -0
  174. package/dist/reconcile/matcher.js +306 -0
  175. package/dist/reconcile/memory.d.ts +120 -0
  176. package/dist/reconcile/memory.js +281 -0
  177. package/dist/reconcile/posting.d.ts +185 -0
  178. package/dist/reconcile/posting.js +481 -0
  179. package/dist/reconcile/scoring.d.ts +98 -0
  180. package/dist/reconcile/scoring.js +319 -0
  181. package/dist/reconcile/similarity.d.ts +88 -0
  182. package/dist/reconcile/similarity.js +272 -0
  183. package/dist/reconcile/subsetSum.d.ts +61 -0
  184. package/dist/reconcile/subsetSum.js +136 -0
  185. package/dist/reports/ageing.d.ts +75 -0
  186. package/dist/reports/ageing.js +149 -0
  187. package/dist/reports/balanceSheet.d.ts +57 -0
  188. package/dist/reports/balanceSheet.js +107 -0
  189. package/dist/reports/incomeStatement.d.ts +59 -0
  190. package/dist/reports/incomeStatement.js +132 -0
  191. package/dist/reports/index.d.ts +4 -0
  192. package/dist/reports/index.js +4 -0
  193. package/dist/reports/period.d.ts +37 -0
  194. package/dist/reports/period.js +78 -0
  195. package/dist/statement/amount.d.ts +55 -0
  196. package/dist/statement/amount.js +211 -0
  197. package/dist/statement/columns.d.ts +39 -0
  198. package/dist/statement/columns.js +221 -0
  199. package/dist/statement/csv.d.ts +50 -0
  200. package/dist/statement/csv.js +185 -0
  201. package/dist/statement/dates.d.ts +52 -0
  202. package/dist/statement/dates.js +204 -0
  203. package/dist/statement/duplicates.d.ts +51 -0
  204. package/dist/statement/duplicates.js +101 -0
  205. package/dist/statement/import.d.ts +52 -0
  206. package/dist/statement/import.js +172 -0
  207. package/dist/statement/index.d.ts +20 -0
  208. package/dist/statement/index.js +32 -0
  209. package/dist/statement/line.d.ts +60 -0
  210. package/dist/statement/line.js +73 -0
  211. package/dist/statement/ofx.d.ts +47 -0
  212. package/dist/statement/ofx.js +199 -0
  213. package/package.json +72 -4
@@ -0,0 +1,481 @@
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 { JournalEntry } from "../ledger/entry.js";
37
+ export class PostingRuleError extends Error {
38
+ constructor(message) {
39
+ super(message);
40
+ this.name = "PostingRuleError";
41
+ }
42
+ }
43
+ /**
44
+ * The rules that come in the box, against the standard chart.
45
+ *
46
+ * Ordered, and the order is load-bearing: `INTEREST` outbound is a cost of
47
+ * borrowing and `INTEREST` inbound is income, so the two interest rules must
48
+ * be narrower than the general charges rule that follows them. Opening balance
49
+ * lines are skipped first, before anything can classify them as a receipt.
50
+ *
51
+ * This is a starting point and not a claim about anybody's business. A chart
52
+ * that is not this chart needs its own rules; that is what `--rules` is for.
53
+ */
54
+ export function standardRules() {
55
+ return Object.freeze([
56
+ {
57
+ id: "opening-balance",
58
+ describe: "an opening balance line, which is not a transaction",
59
+ match: { any: ["BALANCE BROUGHT FORWARD", "BROUGHT FORWARD", "OPENING BALANCE", "BALANCE B F"] },
60
+ action: "skip",
61
+ },
62
+ {
63
+ id: "interest-received",
64
+ describe: "interest credited by the bank",
65
+ match: { any: ["INTEREST", "GROSS INT"] },
66
+ direction: "in",
67
+ account: "4300",
68
+ narration: "Interest received",
69
+ },
70
+ {
71
+ id: "interest-paid",
72
+ describe: "interest charged on borrowing",
73
+ match: { all: ["INTEREST"] },
74
+ direction: "out",
75
+ account: "5800",
76
+ narration: "Interest paid",
77
+ },
78
+ {
79
+ id: "bank-charges",
80
+ describe: "a fee charged by the bank itself",
81
+ match: {
82
+ any: [
83
+ "ACCOUNT MAINTENANCE FEE",
84
+ "BANK CHARGE",
85
+ "SERVICE CHARGE",
86
+ "OVERDRAFT FEE",
87
+ "UNPAID ITEM FEE",
88
+ "NON STERLING",
89
+ "TRANSACTION CHARGE",
90
+ ],
91
+ },
92
+ direction: "out",
93
+ account: "5800",
94
+ narration: "Bank charges",
95
+ },
96
+ {
97
+ id: "processing-fees",
98
+ describe: "a card or payment processor's cut",
99
+ match: { any: ["MERCHANT FEE", "PROCESSING FEE", "CARD FEE", "STRIPE FEE", "SUMUP FEE"] },
100
+ direction: "out",
101
+ account: "5500",
102
+ narration: "Payment processing fees",
103
+ },
104
+ {
105
+ id: "payroll",
106
+ describe: "net pay leaving the account",
107
+ match: { any: ["PAYROLL", "NET PAY", "SALARIES", "WAGES"], none: ["HMRC", "PAYE"] },
108
+ direction: "out",
109
+ account: "5200",
110
+ narration: "Payroll — net pay",
111
+ },
112
+ {
113
+ id: "payroll-taxes",
114
+ describe: "PAYE and national insurance settled with the revenue",
115
+ match: { any: ["HMRC PAYE", "PAYE NIC", "PAYE", "NATIONAL INSURANCE"] },
116
+ direction: "out",
117
+ account: "2300",
118
+ narration: "PAYE and NIC",
119
+ },
120
+ {
121
+ id: "vat",
122
+ describe: "a VAT payment or repayment",
123
+ match: { any: ["HMRC VAT", "VAT RETURN", "VAT PAYMENT"] },
124
+ account: "2200",
125
+ narration: "VAT",
126
+ },
127
+ {
128
+ id: "rent",
129
+ describe: "rent on the premises",
130
+ match: { any: ["RENT", "PROPERTY RENT", "LANDLORD"] },
131
+ direction: "out",
132
+ account: "5300",
133
+ narration: "Rent",
134
+ },
135
+ {
136
+ id: "software",
137
+ describe: "a software subscription",
138
+ match: {
139
+ any: ["SOFTWARE", "SUBSCRIPTION", "AWS", "GOOGLE CLOUD", "MICROSOFT", "ADOBE", "SLACK", "GITHUB"],
140
+ },
141
+ direction: "out",
142
+ account: "5400",
143
+ narration: "Software",
144
+ },
145
+ {
146
+ id: "travel",
147
+ describe: "travel and fares",
148
+ match: { any: ["TRAINLINE", "RAIL", "UBER", "AIRLINE", "TAXI", "TFL", "PARKING"] },
149
+ direction: "out",
150
+ account: "5600",
151
+ narration: "Travel",
152
+ },
153
+ {
154
+ id: "professional-fees",
155
+ describe: "accountants, solicitors and the like",
156
+ match: { any: ["SOLICITOR", "ACCOUNTANT", "LEGAL", "AUDIT FEE", "CONSULTANCY FEE"] },
157
+ direction: "out",
158
+ account: "5700",
159
+ narration: "Professional fees",
160
+ },
161
+ {
162
+ id: "drawings",
163
+ describe: "money taken out by the owner",
164
+ match: { any: ["DRAWINGS", "OWNER DRAW", "DIVIDEND"] },
165
+ direction: "out",
166
+ account: "3300",
167
+ narration: "Drawings",
168
+ },
169
+ ]);
170
+ }
171
+ const DEFAULT_PREFIX = "BNK";
172
+ function matches(rule, line) {
173
+ const text = line.normalisedDescription;
174
+ const test = rule.match;
175
+ if (test.none !== undefined && test.none.some((token) => text.includes(token.toUpperCase()))) {
176
+ return false;
177
+ }
178
+ if (test.all !== undefined && !test.all.every((token) => text.includes(token.toUpperCase()))) {
179
+ return false;
180
+ }
181
+ if (test.any !== undefined && !test.any.some((token) => text.includes(token.toUpperCase()))) {
182
+ return false;
183
+ }
184
+ if (test.regex !== undefined) {
185
+ let pattern;
186
+ try {
187
+ pattern = new RegExp(test.regex, "i");
188
+ }
189
+ catch (error) {
190
+ throw new PostingRuleError(`Rule ${rule.id} has an unusable regex: ${error instanceof Error ? error.message : String(error)}`);
191
+ }
192
+ if (!pattern.test(text))
193
+ return false;
194
+ }
195
+ if (test.all === undefined && test.any === undefined && test.regex === undefined) {
196
+ // A rule with only a `none` clause would match everything that is not
197
+ // excluded, which is never what anybody means.
198
+ throw new PostingRuleError(`Rule ${rule.id} matches on nothing`);
199
+ }
200
+ return true;
201
+ }
202
+ /**
203
+ * Which way the money went.
204
+ *
205
+ * Taken from the statement's own convention — positive is money into the
206
+ * account — and not from `inflowSign`. `inflowSign` decides which side of the
207
+ * *ledger* the movement lands on; it does not change the fact that a purchase
208
+ * on a credit card is money going out. Conflating the two is how a card fee
209
+ * ends up classified as income.
210
+ */
211
+ function directionOf(line) {
212
+ return line.amount.isNegative ? "out" : "in";
213
+ }
214
+ function directionAllows(rule, direction) {
215
+ const wanted = rule.direction ?? "either";
216
+ return wanted === "either" || wanted === direction;
217
+ }
218
+ /** The entry id a statement line implies, whoever asks and however often. */
219
+ export function impliedEntryId(line, prefix = DEFAULT_PREFIX) {
220
+ return `${prefix}-${line.fingerprint}`;
221
+ }
222
+ /**
223
+ * Turn a statement line into the entry it implies.
224
+ *
225
+ * The bank account takes the movement as the statement reported it; the
226
+ * contra account takes the other side. For an asset account money in is a
227
+ * debit, which is the identity — but a credit-card control account works the
228
+ * other way round, so the sign is applied explicitly rather than assumed.
229
+ */
230
+ function entryFor(line, options) {
231
+ const bankSide = options.inflowSign === 1 ? line.amount : line.amount.negated();
232
+ const input = {
233
+ id: options.id,
234
+ date: line.date,
235
+ narration: options.narration,
236
+ postings: [
237
+ { account: options.bankAccount, amount: bankSide, memo: line.description },
238
+ { account: options.contraAccount, amount: bankSide.negated(), memo: line.description },
239
+ ],
240
+ tags: options.tags,
241
+ ...(line.reference !== null ? { reference: line.reference } : {}),
242
+ };
243
+ return JournalEntry.create(input, options.chart);
244
+ }
245
+ /**
246
+ * What the ledger is missing, one proposal per statement line handed in.
247
+ *
248
+ * Callers pass the lines that reconciliation could not explain. Every line
249
+ * gets a proposal, including the ones that come to nothing: a queue of
250
+ * eleven lines that produced four entries should say so, and say why the
251
+ * other seven did not.
252
+ */
253
+ export function proposeEntries(lines, options) {
254
+ const rules = options.rules ?? standardRules();
255
+ const inflowSign = options.inflowSign ?? 1;
256
+ const prefix = options.idPrefix ?? DEFAULT_PREFIX;
257
+ const tags = options.tags ?? ["bank-import"];
258
+ for (const rule of rules) {
259
+ if ((rule.action ?? "book") === "book" && rule.account === undefined) {
260
+ throw new PostingRuleError(`Rule ${rule.id} books something but names no account`);
261
+ }
262
+ }
263
+ const proposals = [];
264
+ for (const line of lines) {
265
+ const direction = directionOf(line);
266
+ const rule = rules.find((candidate) => directionAllows(candidate, direction) && matches(candidate, line));
267
+ if (rule !== undefined && (rule.action ?? "book") === "skip") {
268
+ proposals.push(Object.freeze({
269
+ line,
270
+ outcome: "skip",
271
+ rule,
272
+ entry: null,
273
+ account: null,
274
+ reason: `not booked: ${rule.describe}`,
275
+ }));
276
+ continue;
277
+ }
278
+ const account = rule?.account ?? options.suspenseAccount;
279
+ if (account === undefined) {
280
+ proposals.push(Object.freeze({
281
+ line,
282
+ outcome: "unclassified",
283
+ rule: null,
284
+ entry: null,
285
+ account: null,
286
+ reason: "no rule matched, and no suspense account was named",
287
+ }));
288
+ continue;
289
+ }
290
+ const id = impliedEntryId(line, prefix);
291
+ if (options.ledger?.has(id) === true) {
292
+ proposals.push(Object.freeze({
293
+ line,
294
+ outcome: "already-booked",
295
+ rule: rule ?? null,
296
+ entry: null,
297
+ account,
298
+ reason: `already in the ledger as ${id}`,
299
+ }));
300
+ continue;
301
+ }
302
+ const entry = entryFor(line, {
303
+ bankAccount: options.account,
304
+ contraAccount: account,
305
+ narration: rule?.narration ?? line.description,
306
+ id,
307
+ inflowSign,
308
+ chart: options.chart ?? options.ledger?.chart,
309
+ tags,
310
+ });
311
+ proposals.push(Object.freeze({
312
+ line,
313
+ outcome: "book",
314
+ rule: rule ?? null,
315
+ entry,
316
+ account,
317
+ reason: rule === undefined
318
+ ? `no rule matched; posted to the suspense account ${account}`
319
+ : `${rule.describe} → ${account}`,
320
+ }));
321
+ }
322
+ return Object.freeze(proposals);
323
+ }
324
+ /** Post everything a proposal set says to post, in the order it was proposed. */
325
+ export function applyProposals(ledger, proposals) {
326
+ const entries = proposals
327
+ .filter((proposal) => proposal.outcome === "book" && proposal.entry !== null)
328
+ .map((proposal) => proposal.entry);
329
+ return ledger.postMany(entries);
330
+ }
331
+ export function summariseProposals(proposals, currency) {
332
+ const zero = Money.zero(currency);
333
+ const byAccount = new Map();
334
+ let net = zero;
335
+ for (const proposal of proposals) {
336
+ if (proposal.outcome !== "book" || proposal.account === null)
337
+ continue;
338
+ net = net.plus(proposal.line.amount);
339
+ const existing = byAccount.get(proposal.account) ?? { amount: zero, count: 0 };
340
+ byAccount.set(proposal.account, {
341
+ amount: existing.amount.plus(proposal.line.amount),
342
+ count: existing.count + 1,
343
+ });
344
+ }
345
+ const count = (outcome) => proposals.filter((proposal) => proposal.outcome === outcome).length;
346
+ return Object.freeze({
347
+ total: proposals.length,
348
+ booked: count("book"),
349
+ skipped: count("skip"),
350
+ unclassified: count("unclassified"),
351
+ alreadyBooked: count("already-booked"),
352
+ net,
353
+ byAccount: Object.freeze([...byAccount.entries()]
354
+ .map(([account, totals]) => ({ account, amount: totals.amount, count: totals.count }))
355
+ .sort((a, b) => Number(b.amount.abs().minorUnits - a.amount.abs().minorUnits) ||
356
+ (a.account < b.account ? -1 : 1))),
357
+ });
358
+ }
359
+ /** Read a rules file. */
360
+ export function parseRules(text) {
361
+ let parsed;
362
+ try {
363
+ parsed = JSON.parse(text);
364
+ }
365
+ catch (error) {
366
+ throw new PostingRuleError(`The rules file is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
367
+ }
368
+ if (!Array.isArray(parsed))
369
+ throw new PostingRuleError("The rules file must be a JSON array");
370
+ return Object.freeze(parsed.map((value, index) => {
371
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
372
+ throw new PostingRuleError(`Rule ${index} must be an object`);
373
+ }
374
+ const raw = value;
375
+ if (typeof raw["id"] !== "string" || raw["id"].trim() === "") {
376
+ throw new PostingRuleError(`Rule ${index} needs an id`);
377
+ }
378
+ const id = raw["id"];
379
+ const action = raw["action"] ?? "book";
380
+ if (action !== "book" && action !== "skip") {
381
+ throw new PostingRuleError(`Rule ${id} has an action that is neither book nor skip`);
382
+ }
383
+ if (action === "book" && typeof raw["account"] !== "string") {
384
+ throw new PostingRuleError(`Rule ${id} books something but names no account`);
385
+ }
386
+ const direction = raw["direction"] ?? "either";
387
+ if (direction !== "in" && direction !== "out" && direction !== "either") {
388
+ throw new PostingRuleError(`Rule ${id} has a direction that is not in, out or either`);
389
+ }
390
+ const match = raw["match"];
391
+ if (typeof match !== "object" || match === null || Array.isArray(match)) {
392
+ throw new PostingRuleError(`Rule ${id} needs a match object`);
393
+ }
394
+ const clauses = match;
395
+ for (const key of ["all", "any", "none"]) {
396
+ const clause = clauses[key];
397
+ if (clause === undefined)
398
+ continue;
399
+ if (!Array.isArray(clause) || clause.some((token) => typeof token !== "string")) {
400
+ throw new PostingRuleError(`Rule ${id} has a match.${key} that is not a list of strings`);
401
+ }
402
+ }
403
+ if (clauses["regex"] !== undefined && typeof clauses["regex"] !== "string") {
404
+ throw new PostingRuleError(`Rule ${id} has a match.regex that is not a string`);
405
+ }
406
+ return Object.freeze({
407
+ id,
408
+ describe: typeof raw["describe"] === "string" ? raw["describe"] : id,
409
+ match: Object.freeze({ ...match }),
410
+ direction: direction,
411
+ action: action,
412
+ ...(typeof raw["account"] === "string" ? { account: raw["account"] } : {}),
413
+ ...(typeof raw["narration"] === "string" ? { narration: raw["narration"] } : {}),
414
+ });
415
+ }));
416
+ }
417
+ /** The proposals, as something a person can read down. */
418
+ export function renderProposals(proposals, summary) {
419
+ if (proposals.length === 0)
420
+ return "Nothing on the statement is missing from the books.";
421
+ const rows = proposals.map((proposal) => [
422
+ proposal.line.date,
423
+ proposal.line.description.length > 34
424
+ ? `${proposal.line.description.slice(0, 33)}…`
425
+ : proposal.line.description,
426
+ proposal.line.amount.toDecimalString(),
427
+ proposal.account ?? "—",
428
+ proposal.outcome === "book" ? (proposal.rule?.id ?? "suspense") : proposal.outcome,
429
+ ]);
430
+ const header = ["date", "description", "amount", "account", "rule"];
431
+ const numeric = [false, false, true, false, false];
432
+ const widths = header.map((cell, column) => Math.max(cell.length, ...rows.map((row) => row[column].length)));
433
+ const format = (cells) => cells
434
+ .map((cell, column) => numeric[column] === true
435
+ ? cell.padStart(widths[column])
436
+ : cell.padEnd(widths[column]))
437
+ .join(" ")
438
+ .trimEnd();
439
+ const lines = [format(header), ...rows.map(format), ""];
440
+ lines.push(`${summary.booked} to book, ${summary.skipped} skipped, ` +
441
+ `${summary.alreadyBooked} already in the ledger, ${summary.unclassified} unclassified`);
442
+ if (summary.booked > 0) {
443
+ lines.push(`Net effect on the bank account: ${summary.net.toDecimalString()}`);
444
+ lines.push("");
445
+ for (const total of summary.byAccount) {
446
+ lines.push(` ${total.account} ${total.amount.toDecimalString().padStart(12)} ` +
447
+ `(${total.count} ${total.count === 1 ? "line" : "lines"})`);
448
+ }
449
+ }
450
+ return lines.join("\n");
451
+ }
452
+ /**
453
+ * Which statement lines still need booking.
454
+ *
455
+ * The unmatched ones, plainly — nothing in the books corresponds to them. And
456
+ * then the ones sitting under a suggestion the reviewer has already refused:
457
+ * a rejected pairing is a person saying this line does *not* belong to that
458
+ * entry, which leaves the line with no counterpart at all.
459
+ *
460
+ * A suggestion nobody has decided is left alone. The matcher believes there is
461
+ * a ledger entry behind it, and booking a second one would double-count the
462
+ * transaction. Undecided means unproposed, which is the conservative reading
463
+ * and the right one: a missed entry is a reconciliation that does not balance,
464
+ * and a duplicated entry is a reconciliation that balances and is wrong.
465
+ *
466
+ * "Refused" means every description pair in the suggestion recalls as rejected.
467
+ * A batch where the reviewer refused one of four suppliers is still, in part,
468
+ * something the books know about.
469
+ */
470
+ export function linesNeedingEntries(result, memory) {
471
+ const lines = [...result.unmatchedStatement];
472
+ if (memory !== undefined && memory.size > 0) {
473
+ for (const suggestion of result.suggested) {
474
+ const pairs = suggestion.statement.flatMap((statement) => suggestion.book.map((book) => memory.recall(statement.description, book.description)));
475
+ if (pairs.length > 0 && pairs.every((verdict) => verdict.kind === "rejected")) {
476
+ lines.push(...suggestion.statement);
477
+ }
478
+ }
479
+ }
480
+ return Object.freeze(lines.sort((a, b) => (a.date === b.date ? (a.id < b.id ? -1 : 1) : a.date < b.date ? -1 : 1)));
481
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * How good is this match, and why?
3
+ *
4
+ * The "why" is not decoration. A reconciliation engine that emits a number and
5
+ * nothing else leaves a reviewer with two options: trust it blindly, or redo
6
+ * the work by hand. So every score comes with the rules that produced it, each
7
+ * carrying its own sub-score and how much it moved the total.
8
+ *
9
+ * Two of the rules are gates rather than contributions. A payment that differs
10
+ * by £40 is not a weak match, it is a different transaction; money in and money
11
+ * out are not a near miss. Both reject outright, and the caller is told which
12
+ * gate closed. The remaining rules — date proximity, description similarity,
13
+ * shared references — are weighted and averaged.
14
+ *
15
+ * The reference rule is dropped from the average when neither side carries a
16
+ * reference at all. Scoring a missing signal as zero would punish every cash
17
+ * transaction in the book for something it could never have had.
18
+ */
19
+ import type { StatementLine } from "../statement/line.js";
20
+ import type { BookLine } from "./bankView.js";
21
+ import type { MatchMemory } from "./memory.js";
22
+ export type RuleName = "amount" | "direction" | "date" | "description" | "reference" | "memory";
23
+ export interface MatchReason {
24
+ readonly rule: RuleName;
25
+ /** Human-readable, and meant to be read: this is what the reviewer sees. */
26
+ readonly detail: string;
27
+ /** This rule's own verdict, `0..1`. */
28
+ readonly score: number;
29
+ /** Its weight in the average; `0` for a rule that did not apply. */
30
+ readonly weight: number;
31
+ /** `score * weight`, before normalisation. */
32
+ readonly contribution: number;
33
+ }
34
+ export type Confidence = "exact" | "high" | "medium" | "low" | "rejected";
35
+ export interface ScoredMatch {
36
+ readonly score: number;
37
+ readonly confidence: Confidence;
38
+ readonly reasons: readonly MatchReason[];
39
+ /** Set when a gate rejected the pair outright. */
40
+ readonly rejectedBy: RuleName | null;
41
+ /** Statement date minus book date, in days. */
42
+ readonly dayGap: number;
43
+ /** Statement amount minus book amount, in minor units. */
44
+ readonly amountGap: bigint;
45
+ }
46
+ export interface ScoringWeights {
47
+ readonly amount: number;
48
+ readonly date: number;
49
+ readonly description: number;
50
+ readonly reference: number;
51
+ readonly memory: number;
52
+ }
53
+ export interface ScoringOptions {
54
+ /** Beyond this many days apart, a pair is rejected. Default 7. */
55
+ dateWindowDays?: number;
56
+ /** Amounts may differ by at most this many minor units. Default 0. */
57
+ amountToleranceMinorUnits?: bigint;
58
+ weights?: Partial<ScoringWeights>;
59
+ /** Score at or above which a match is safe to post unreviewed. Default 0.86. */
60
+ autoAcceptScore?: number;
61
+ /** Score below which a pair is not worth showing at all. Default 0.45. */
62
+ suggestScore?: number;
63
+ /**
64
+ * What a reviewer has already confirmed or refused. Omit it and the memory
65
+ * rule never applies, and scoring is exactly what it was before memory
66
+ * existed.
67
+ */
68
+ memory?: MatchMemory;
69
+ }
70
+ export interface ResolvedScoringOptions {
71
+ readonly dateWindowDays: number;
72
+ readonly amountToleranceMinorUnits: bigint;
73
+ readonly weights: ScoringWeights;
74
+ readonly autoAcceptScore: number;
75
+ readonly suggestScore: number;
76
+ readonly memory: MatchMemory | undefined;
77
+ }
78
+ /**
79
+ * Amount and date carry most of the weight, and deliberately so. A bank
80
+ * descriptor and a narration are written by different people for different
81
+ * purposes; agreeing on the penny and the day is much stronger evidence than
82
+ * agreeing on the wording, and description is best used to break ties between
83
+ * candidates that already agree on the numbers.
84
+ */
85
+ export declare const DEFAULT_WEIGHTS: ScoringWeights;
86
+ export declare function resolveScoringOptions(options?: ScoringOptions): ResolvedScoringOptions;
87
+ /** Score one ledger movement against one statement line. */
88
+ export declare function scorePair(book: BookLine, line: StatementLine, options?: ScoringOptions): ScoredMatch;
89
+ /**
90
+ * Score a group: several ledger movements against several statement lines.
91
+ *
92
+ * The group is collapsed to its total, its earliest date, and its descriptions
93
+ * joined, then scored as though it were a single pair. Joining the text is the
94
+ * right move rather than a shortcut — a batch payment's bank descriptor often
95
+ * names one of the invoices in it, and the token-set similarity finds that
96
+ * inside the joined string.
97
+ */
98
+ export declare function scoreGroup(books: readonly BookLine[], lines: readonly StatementLine[], options?: ScoringOptions): ScoredMatch;