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,300 @@
1
+ /**
2
+ * `Money` — an exact amount in a single currency.
3
+ *
4
+ * The amount is a bigint count of minor units (pence, cents, yen). There is no
5
+ * float anywhere in this file and no way to construct a `Money` from one
6
+ * without going through an explicit, rounded conversion.
7
+ *
8
+ * Binary operations between different currencies throw. That is deliberate:
9
+ * a silent currency mix-up is the kind of bug that reconciles to zero and is
10
+ * wrong by thousands.
11
+ */
12
+ import { currency as lookupCurrency, minorUnitScale, } from "./currency.js";
13
+ import { decimalToRational, divideRound, numberToRational, } from "./rounding.js";
14
+ export class CurrencyMismatchError extends Error {
15
+ left;
16
+ right;
17
+ constructor(left, right) {
18
+ super(`Currency mismatch: ${left.code} and ${right.code}`);
19
+ this.left = left;
20
+ this.right = right;
21
+ this.name = "CurrencyMismatchError";
22
+ }
23
+ }
24
+ export class Money {
25
+ /** Signed count of minor units. */
26
+ minorUnits;
27
+ currency;
28
+ constructor(minorUnits, currency) {
29
+ this.minorUnits = minorUnits;
30
+ this.currency = currency;
31
+ Object.freeze(this);
32
+ }
33
+ // ---------------------------------------------------------------- factories
34
+ /** Construct directly from minor units: `Money.ofMinor(1250n, GBP)` is £12.50. */
35
+ static ofMinor(minorUnits, currency) {
36
+ const c = typeof currency === "string" ? lookupCurrency(currency) : currency;
37
+ if (typeof minorUnits === "number") {
38
+ if (!Number.isSafeInteger(minorUnits)) {
39
+ throw new RangeError(`Minor units must be a safe integer, got: ${minorUnits}`);
40
+ }
41
+ return new Money(BigInt(minorUnits), c);
42
+ }
43
+ return new Money(minorUnits, c);
44
+ }
45
+ /**
46
+ * Parse a decimal string in major units: `Money.parse("12.50", GBP)`.
47
+ *
48
+ * Extra precision is an error rather than a silent rounding, because a
49
+ * statement line reading `1.005` means the importer misread the file.
50
+ * Pass a rounding mode to opt into truncation explicitly.
51
+ */
52
+ static parse(text, currency, rounding) {
53
+ const c = typeof currency === "string" ? lookupCurrency(currency) : currency;
54
+ const cleaned = text.trim().replace(/,/g, "");
55
+ const { numerator, denominator } = decimalToRational(cleaned);
56
+ const scale = minorUnitScale(c);
57
+ const scaledNumerator = numerator * scale;
58
+ if (rounding === undefined && scaledNumerator % denominator !== 0n) {
59
+ throw new RangeError(`${text} has more precision than ${c.code} allows (${c.exponent} dp)`);
60
+ }
61
+ return new Money(divideRound(scaledNumerator, denominator, rounding ?? "half-even"), c);
62
+ }
63
+ /** Zero in the given currency. */
64
+ static zero(currency) {
65
+ return Money.ofMinor(0n, currency);
66
+ }
67
+ /**
68
+ * Convert from a JS number of major units. Lossy by nature, so the rounding
69
+ * mode is required at the call site — no defaults to hide behind.
70
+ */
71
+ static fromNumber(value, currency, rounding) {
72
+ const c = typeof currency === "string" ? lookupCurrency(currency) : currency;
73
+ const { numerator, denominator } = numberToRational(value);
74
+ return new Money(divideRound(numerator * minorUnitScale(c), denominator, rounding), c);
75
+ }
76
+ // --------------------------------------------------------------- predicates
77
+ get isZero() {
78
+ return this.minorUnits === 0n;
79
+ }
80
+ get isPositive() {
81
+ return this.minorUnits > 0n;
82
+ }
83
+ get isNegative() {
84
+ return this.minorUnits < 0n;
85
+ }
86
+ get sign() {
87
+ if (this.minorUnits > 0n)
88
+ return 1;
89
+ if (this.minorUnits < 0n)
90
+ return -1;
91
+ return 0;
92
+ }
93
+ sameCurrency(other) {
94
+ return this.currency.code === other.currency.code;
95
+ }
96
+ assertSameCurrency(other) {
97
+ if (!this.sameCurrency(other)) {
98
+ throw new CurrencyMismatchError(this.currency, other.currency);
99
+ }
100
+ }
101
+ // --------------------------------------------------------------- arithmetic
102
+ plus(other) {
103
+ this.assertSameCurrency(other);
104
+ return new Money(this.minorUnits + other.minorUnits, this.currency);
105
+ }
106
+ minus(other) {
107
+ this.assertSameCurrency(other);
108
+ return new Money(this.minorUnits - other.minorUnits, this.currency);
109
+ }
110
+ negated() {
111
+ return new Money(-this.minorUnits, this.currency);
112
+ }
113
+ abs() {
114
+ return this.minorUnits < 0n ? this.negated() : this;
115
+ }
116
+ /** Multiply by a whole number. Always exact — no rounding mode needed. */
117
+ timesInteger(factor) {
118
+ const f = typeof factor === "number" ? BigInt(assertSafeInteger(factor)) : factor;
119
+ return new Money(this.minorUnits * f, this.currency);
120
+ }
121
+ /**
122
+ * Multiply by an arbitrary factor — a rate, a percentage, a decimal string.
123
+ * Rounds to whole minor units using the given mode (banker's by default).
124
+ */
125
+ times(factor, rounding = "half-even") {
126
+ if (typeof factor === "bigint")
127
+ return this.timesInteger(factor);
128
+ const { numerator, denominator } = typeof factor === "number" ? numberToRational(factor) : decimalToRational(factor);
129
+ return new Money(divideRound(this.minorUnits * numerator, denominator, rounding), this.currency);
130
+ }
131
+ /** Divide by a number, rounding to whole minor units. */
132
+ dividedBy(divisor, rounding = "half-even") {
133
+ const { numerator, denominator } = typeof divisor === "bigint"
134
+ ? { numerator: divisor, denominator: 1n }
135
+ : typeof divisor === "number"
136
+ ? numberToRational(divisor)
137
+ : decimalToRational(divisor);
138
+ if (numerator === 0n)
139
+ throw new RangeError("Division by zero");
140
+ return new Money(divideRound(this.minorUnits * denominator, numerator, rounding), this.currency);
141
+ }
142
+ /** The ratio of two amounts as a plain number. Presentation only. */
143
+ ratioTo(other) {
144
+ this.assertSameCurrency(other);
145
+ if (other.minorUnits === 0n)
146
+ throw new RangeError("Division by zero");
147
+ return Number(this.minorUnits) / Number(other.minorUnits);
148
+ }
149
+ // ---------------------------------------------------------------- splitting
150
+ /**
151
+ * Split into `n` parts that sum exactly back to the original.
152
+ *
153
+ * The remainder is handed out one minor unit at a time from the front, so
154
+ * £100 / 3 gives 33.34, 33.33, 33.33. Negative amounts distribute the
155
+ * remainder the same way, keeping `split(n).reduce(add) === original`.
156
+ */
157
+ split(parts) {
158
+ if (!Number.isSafeInteger(parts) || parts <= 0) {
159
+ throw new RangeError(`Split count must be a positive integer, got: ${parts}`);
160
+ }
161
+ const n = BigInt(parts);
162
+ const base = this.minorUnits / n; // bigint division truncates toward zero
163
+ let remainder = this.minorUnits - base * n;
164
+ const step = remainder < 0n ? -1n : 1n;
165
+ const out = [];
166
+ for (let i = 0n; i < n; i++) {
167
+ let share = base;
168
+ if (remainder !== 0n) {
169
+ share += step;
170
+ remainder -= step;
171
+ }
172
+ out.push(new Money(share, this.currency));
173
+ }
174
+ return out;
175
+ }
176
+ /**
177
+ * Split in proportion to `weights` using the largest-remainder method.
178
+ *
179
+ * Every unit is accounted for: the parts always sum to the original. Where
180
+ * two candidates have the same remainder the earlier one wins, which makes
181
+ * the result deterministic rather than merely correct in aggregate.
182
+ */
183
+ allocate(weights) {
184
+ if (weights.length === 0)
185
+ throw new RangeError("Allocation needs at least one weight");
186
+ const w = weights.map((x) => (typeof x === "bigint" ? x : BigInt(assertSafeInteger(x))));
187
+ if (w.some((x) => x < 0n))
188
+ throw new RangeError("Allocation weights must be non-negative");
189
+ const total = w.reduce((a, b) => a + b, 0n);
190
+ if (total === 0n)
191
+ throw new RangeError("Allocation weights must not all be zero");
192
+ const negative = this.minorUnits < 0n;
193
+ const magnitude = negative ? -this.minorUnits : this.minorUnits;
194
+ const shares = w.map((weight) => (magnitude * weight) / total);
195
+ const remainders = w.map((weight, i) => {
196
+ const share = shares[i];
197
+ return { index: i, remainder: magnitude * weight - share * total };
198
+ });
199
+ let leftover = magnitude - shares.reduce((a, b) => a + b, 0n);
200
+ remainders.sort((a, b) => a.remainder === b.remainder ? a.index - b.index : a.remainder > b.remainder ? -1 : 1);
201
+ for (const entry of remainders) {
202
+ if (leftover <= 0n)
203
+ break;
204
+ shares[entry.index] = shares[entry.index] + 1n;
205
+ leftover -= 1n;
206
+ }
207
+ return shares.map((s) => new Money(negative ? -s : s, this.currency));
208
+ }
209
+ // -------------------------------------------------------------- comparisons
210
+ compare(other) {
211
+ this.assertSameCurrency(other);
212
+ if (this.minorUnits > other.minorUnits)
213
+ return 1;
214
+ if (this.minorUnits < other.minorUnits)
215
+ return -1;
216
+ return 0;
217
+ }
218
+ equals(other) {
219
+ return this.sameCurrency(other) && this.minorUnits === other.minorUnits;
220
+ }
221
+ greaterThan(other) {
222
+ return this.compare(other) > 0;
223
+ }
224
+ greaterThanOrEqual(other) {
225
+ return this.compare(other) >= 0;
226
+ }
227
+ lessThan(other) {
228
+ return this.compare(other) < 0;
229
+ }
230
+ lessThanOrEqual(other) {
231
+ return this.compare(other) <= 0;
232
+ }
233
+ /** True when the two differ by no more than `tolerance` (which must be >= 0). */
234
+ within(other, tolerance) {
235
+ this.assertSameCurrency(other);
236
+ this.assertSameCurrency(tolerance);
237
+ if (tolerance.isNegative)
238
+ throw new RangeError("Tolerance must be non-negative");
239
+ return this.minus(other).abs().lessThanOrEqual(tolerance);
240
+ }
241
+ // ------------------------------------------------------------- presentation
242
+ /** The amount as a decimal string in major units, e.g. "-12.50". */
243
+ toDecimalString() {
244
+ const scale = minorUnitScale(this.currency);
245
+ const negative = this.minorUnits < 0n;
246
+ const magnitude = negative ? -this.minorUnits : this.minorUnits;
247
+ const whole = magnitude / scale;
248
+ const sign = negative ? "-" : "";
249
+ if (this.currency.exponent === 0)
250
+ return `${sign}${whole}`;
251
+ const fraction = (magnitude % scale).toString().padStart(this.currency.exponent, "0");
252
+ return `${sign}${whole}.${fraction}`;
253
+ }
254
+ /** Human-facing form with symbol and thousands separators: "£1,234.50". */
255
+ format(options = {}) {
256
+ const { symbol = true, grouping = true } = options;
257
+ const decimal = this.toDecimalString();
258
+ const negative = decimal.startsWith("-");
259
+ const body = negative ? decimal.slice(1) : decimal;
260
+ const dot = body.indexOf(".");
261
+ let whole = dot === -1 ? body : body.slice(0, dot);
262
+ const fraction = dot === -1 ? "" : body.slice(dot);
263
+ if (grouping)
264
+ whole = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
265
+ const prefix = symbol ? this.currency.symbol : "";
266
+ return `${negative ? "-" : ""}${prefix}${whole}${fraction}`;
267
+ }
268
+ toString() {
269
+ return `${this.toDecimalString()} ${this.currency.code}`;
270
+ }
271
+ toJSON() {
272
+ return { amount: this.minorUnits.toString(), currency: this.currency.code };
273
+ }
274
+ static fromJSON(value) {
275
+ return Money.ofMinor(BigInt(value.amount), lookupCurrency(value.currency));
276
+ }
277
+ }
278
+ function assertSafeInteger(value) {
279
+ if (!Number.isSafeInteger(value)) {
280
+ throw new RangeError(`Expected a safe integer, got: ${value}`);
281
+ }
282
+ return value;
283
+ }
284
+ /** Sum a list of amounts. Requires the currency when the list may be empty. */
285
+ export function sumMoney(amounts, currency) {
286
+ const first = amounts[0];
287
+ if (first === undefined) {
288
+ if (currency === undefined) {
289
+ throw new RangeError("Cannot sum an empty list without an explicit currency");
290
+ }
291
+ return Money.zero(currency);
292
+ }
293
+ return amounts.reduce((a, b) => a.plus(b), Money.zero(currency ?? first.currency));
294
+ }
295
+ export function minMoney(a, b) {
296
+ return a.lessThanOrEqual(b) ? a : b;
297
+ }
298
+ export function maxMoney(a, b) {
299
+ return a.greaterThanOrEqual(b) ? a : b;
300
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Integer division with an explicit rounding policy.
3
+ *
4
+ * Every rounding decision in the accounting path routes through here, so there
5
+ * is exactly one place where the tie-breaking rules live. All arithmetic is on
6
+ * bigints: nothing in this file can lose precision, only choose a direction.
7
+ */
8
+ export type RoundingMode =
9
+ /** Toward +infinity. */
10
+ "ceil"
11
+ /** Toward -infinity. */
12
+ | "floor"
13
+ /** Toward zero (truncate). */
14
+ | "down"
15
+ /** Away from zero. */
16
+ | "up"
17
+ /** Nearest; ties away from zero. The intuitive schoolbook rule. */
18
+ | "half-up"
19
+ /** Nearest; ties toward zero. */
20
+ | "half-down"
21
+ /** Nearest; ties to the even neighbour. Banker's rounding. */
22
+ | "half-even";
23
+ export declare const ROUNDING_MODES: readonly RoundingMode[];
24
+ /**
25
+ * Divide `numerator` by `denominator`, returning an exact integer under the
26
+ * given rounding mode. Throws on a zero denominator.
27
+ */
28
+ export declare function divideRound(numerator: bigint, denominator: bigint, mode?: RoundingMode): bigint;
29
+ /**
30
+ * Parse a decimal literal into an exact rational. Accepts optional sign,
31
+ * digits, a fractional part and scientific notation: "-1.25", "3", "2.5e-3".
32
+ */
33
+ export declare function decimalToRational(input: string): {
34
+ numerator: bigint;
35
+ denominator: bigint;
36
+ };
37
+ /** Convert a finite JS number to an exact rational via its shortest decimal form. */
38
+ export declare function numberToRational(value: number): {
39
+ numerator: bigint;
40
+ denominator: bigint;
41
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Integer division with an explicit rounding policy.
3
+ *
4
+ * Every rounding decision in the accounting path routes through here, so there
5
+ * is exactly one place where the tie-breaking rules live. All arithmetic is on
6
+ * bigints: nothing in this file can lose precision, only choose a direction.
7
+ */
8
+ export const ROUNDING_MODES = [
9
+ "ceil",
10
+ "floor",
11
+ "down",
12
+ "up",
13
+ "half-up",
14
+ "half-down",
15
+ "half-even",
16
+ ];
17
+ function abs(v) {
18
+ return v < 0n ? -v : v;
19
+ }
20
+ /**
21
+ * Divide `numerator` by `denominator`, returning an exact integer under the
22
+ * given rounding mode. Throws on a zero denominator.
23
+ */
24
+ export function divideRound(numerator, denominator, mode = "half-even") {
25
+ if (denominator === 0n)
26
+ throw new RangeError("Division by zero");
27
+ // Normalise so the denominator is positive; the sign lives in the numerator.
28
+ let n = numerator;
29
+ let d = denominator;
30
+ if (d < 0n) {
31
+ n = -n;
32
+ d = -d;
33
+ }
34
+ const negative = n < 0n;
35
+ const magnitude = abs(n);
36
+ const quotient = magnitude / d;
37
+ const remainder = magnitude - quotient * d;
38
+ if (remainder === 0n)
39
+ return negative ? -quotient : quotient;
40
+ // `roundAway` decides whether to move the magnitude up to the next integer.
41
+ let roundAway;
42
+ switch (mode) {
43
+ case "down":
44
+ roundAway = false;
45
+ break;
46
+ case "up":
47
+ roundAway = true;
48
+ break;
49
+ case "ceil":
50
+ roundAway = !negative;
51
+ break;
52
+ case "floor":
53
+ roundAway = negative;
54
+ break;
55
+ case "half-up":
56
+ case "half-down":
57
+ case "half-even": {
58
+ const twice = remainder * 2n;
59
+ if (twice > d) {
60
+ roundAway = true;
61
+ }
62
+ else if (twice < d) {
63
+ roundAway = false;
64
+ }
65
+ else if (mode === "half-up") {
66
+ roundAway = true;
67
+ }
68
+ else if (mode === "half-down") {
69
+ roundAway = false;
70
+ }
71
+ else {
72
+ roundAway = quotient % 2n !== 0n;
73
+ }
74
+ break;
75
+ }
76
+ }
77
+ const rounded = roundAway ? quotient + 1n : quotient;
78
+ return negative ? -rounded : rounded;
79
+ }
80
+ /**
81
+ * Parse a decimal literal into an exact rational. Accepts optional sign,
82
+ * digits, a fractional part and scientific notation: "-1.25", "3", "2.5e-3".
83
+ */
84
+ export function decimalToRational(input) {
85
+ const text = input.trim();
86
+ const match = /^([+-]?)(\d*)(?:\.(\d*))?(?:[eE]([+-]?\d+))?$/.exec(text);
87
+ if (match === null)
88
+ throw new RangeError(`Not a decimal number: ${JSON.stringify(input)}`);
89
+ const [, sign = "", whole = "", fraction = "", exponent] = match;
90
+ if (whole === "" && fraction === "") {
91
+ throw new RangeError(`Not a decimal number: ${JSON.stringify(input)}`);
92
+ }
93
+ let numerator = BigInt((whole === "" ? "0" : whole) + fraction);
94
+ let denominator = 10n ** BigInt(fraction.length);
95
+ if (exponent !== undefined) {
96
+ let e = BigInt(exponent);
97
+ // Cancel against the fractional denominator before growing the numerator,
98
+ // so "2.5e2" comes back as 250/1 rather than 2500/10.
99
+ while (e > 0n && denominator % 10n === 0n && denominator > 1n) {
100
+ denominator /= 10n;
101
+ e -= 1n;
102
+ }
103
+ if (e > 0n)
104
+ numerator *= 10n ** e;
105
+ else if (e < 0n)
106
+ denominator *= 10n ** -e;
107
+ }
108
+ if (sign === "-")
109
+ numerator = -numerator;
110
+ return { numerator, denominator };
111
+ }
112
+ /** Convert a finite JS number to an exact rational via its shortest decimal form. */
113
+ export function numberToRational(value) {
114
+ if (!Number.isFinite(value))
115
+ throw new RangeError(`Factor must be finite, got: ${value}`);
116
+ return decimalToRational(value.toString());
117
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Scoring the matcher itself.
3
+ *
4
+ * A reconciliation engine can be wrong in two directions and they are not
5
+ * equally bad. Missing a match costs a reviewer a minute. *Making* a wrong
6
+ * match costs them an afternoon, because the evidence has been consumed and the
7
+ * two lines it should have paired are now stranded somewhere else in the
8
+ * report. So the two are reported separately rather than folded into one
9
+ * accuracy number that hides which kind of wrong it is.
10
+ *
11
+ * The vocabulary is the standard one, applied to whole matches rather than
12
+ * lines. A match is *correct* when the exact set of book lines and the exact
13
+ * set of statement lines in it agree with the ground truth; a batch that got
14
+ * eight of its nine invoices is not eight-ninths right, it is wrong, because a
15
+ * reviewer given it would have to redo the work.
16
+ *
17
+ * `suggested` matches are counted apart from `matched` ones throughout. A
18
+ * suggestion the engine got wrong is not a failure of the same kind: it was
19
+ * shown to a human and labelled as needing a decision, which is the system
20
+ * working as designed.
21
+ */
22
+ import type { ReconciliationResult } from "./matcher.js";
23
+ /** One statement line and the book lines it really came from. */
24
+ export interface TruthLink {
25
+ readonly statementId: string;
26
+ readonly bookIds: readonly string[];
27
+ }
28
+ export interface AccuracyReport {
29
+ /** Auto-accepted matches that agree with the truth exactly. */
30
+ readonly correct: number;
31
+ /** Auto-accepted matches that pair lines the truth says do not belong together. */
32
+ readonly wrong: number;
33
+ /** True links the engine neither matched nor suggested. */
34
+ readonly missed: number;
35
+ /** Suggestions that turn out to be right — work saved, but not claimed. */
36
+ readonly suggestedCorrect: number;
37
+ /** Suggestions that turn out to be wrong — a reviewer's time, not an error. */
38
+ readonly suggestedWrong: number;
39
+ /** Links in the ground truth. */
40
+ readonly expected: number;
41
+ /**
42
+ * Of the pairings the engine committed to, the share that were right.
43
+ * The number that matters: a low precision means it is inventing matches.
44
+ */
45
+ readonly precision: number;
46
+ /** Of the pairings that existed, the share the engine committed to. */
47
+ readonly recall: number;
48
+ /** Recall counting suggestions as found, which is what a reviewer sees. */
49
+ readonly recallWithSuggestions: number;
50
+ /** Harmonic mean of precision and recall over committed matches. */
51
+ readonly f1: number;
52
+ /** The wrong auto-accepted matches, for looking at. */
53
+ readonly failures: readonly AccuracyFailure[];
54
+ }
55
+ export interface AccuracyFailure {
56
+ readonly statementIds: readonly string[];
57
+ readonly bookIds: readonly string[];
58
+ readonly score: number;
59
+ readonly reason: "no truth for these lines" | "paired the wrong lines";
60
+ }
61
+ /**
62
+ * How well did this reconciliation do against what really happened?
63
+ */
64
+ export declare function measureAccuracy(result: ReconciliationResult, truth: readonly TruthLink[]): AccuracyReport;
65
+ export declare function renderAccuracy(report: AccuracyReport): string;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * Scoring the matcher itself.
3
+ *
4
+ * A reconciliation engine can be wrong in two directions and they are not
5
+ * equally bad. Missing a match costs a reviewer a minute. *Making* a wrong
6
+ * match costs them an afternoon, because the evidence has been consumed and the
7
+ * two lines it should have paired are now stranded somewhere else in the
8
+ * report. So the two are reported separately rather than folded into one
9
+ * accuracy number that hides which kind of wrong it is.
10
+ *
11
+ * The vocabulary is the standard one, applied to whole matches rather than
12
+ * lines. A match is *correct* when the exact set of book lines and the exact
13
+ * set of statement lines in it agree with the ground truth; a batch that got
14
+ * eight of its nine invoices is not eight-ninths right, it is wrong, because a
15
+ * reviewer given it would have to redo the work.
16
+ *
17
+ * `suggested` matches are counted apart from `matched` ones throughout. A
18
+ * suggestion the engine got wrong is not a failure of the same kind: it was
19
+ * shown to a human and labelled as needing a decision, which is the system
20
+ * working as designed.
21
+ */
22
+ /** A canonical key for a set of ids, so two orderings compare equal. */
23
+ function setKey(ids) {
24
+ return [...new Set(ids)].sort().join("+");
25
+ }
26
+ function matchKey(match) {
27
+ return {
28
+ statement: setKey(match.statement.map((line) => line.id)),
29
+ book: setKey(match.book.map((line) => line.id)),
30
+ };
31
+ }
32
+ /**
33
+ * Fold the ground truth into the same shape a match has.
34
+ *
35
+ * Truth arrives one statement line at a time, but a many-to-one match covers
36
+ * several statement lines at once, so links that share book lines have to be
37
+ * merged before the two can be compared. Union-find over the shared ids does
38
+ * it: two links belong together exactly when they touch the same book line.
39
+ */
40
+ function truthGroups(truth) {
41
+ const owner = new Map();
42
+ const parent = [];
43
+ const find = (node) => {
44
+ let root = node;
45
+ while (parent[root] !== root)
46
+ root = parent[root];
47
+ let walk = node;
48
+ while (parent[walk] !== walk) {
49
+ const next = parent[walk];
50
+ parent[walk] = root;
51
+ walk = next;
52
+ }
53
+ return root;
54
+ };
55
+ const union = (a, b) => {
56
+ const rootA = find(a);
57
+ const rootB = find(b);
58
+ if (rootA !== rootB)
59
+ parent[rootB] = rootA;
60
+ };
61
+ truth.forEach((link, index) => {
62
+ parent[index] = index;
63
+ for (const id of link.bookIds) {
64
+ const seen = owner.get(id);
65
+ if (seen === undefined)
66
+ owner.set(id, index);
67
+ else
68
+ union(seen, index);
69
+ }
70
+ });
71
+ const statementsByRoot = new Map();
72
+ const booksByRoot = new Map();
73
+ truth.forEach((link, index) => {
74
+ const root = find(index);
75
+ let statements = statementsByRoot.get(root);
76
+ if (statements === undefined) {
77
+ statements = [];
78
+ statementsByRoot.set(root, statements);
79
+ }
80
+ statements.push(link.statementId);
81
+ let books = booksByRoot.get(root);
82
+ if (books === undefined) {
83
+ books = [];
84
+ booksByRoot.set(root, books);
85
+ }
86
+ books.push(...link.bookIds);
87
+ });
88
+ // Keyed by the statement side, valued by the book side: a proposed match is
89
+ // correct when its statement key is present and its book key agrees.
90
+ const groups = new Map();
91
+ for (const [root, statements] of statementsByRoot) {
92
+ groups.set(setKey(statements), setKey(booksByRoot.get(root) ?? []));
93
+ }
94
+ return groups;
95
+ }
96
+ /**
97
+ * How well did this reconciliation do against what really happened?
98
+ */
99
+ export function measureAccuracy(result, truth) {
100
+ const groups = truthGroups(truth);
101
+ const found = new Set();
102
+ const failures = [];
103
+ let correct = 0;
104
+ let wrong = 0;
105
+ for (const match of result.matched) {
106
+ const key = matchKey(match);
107
+ const expected = groups.get(key.statement);
108
+ if (expected === key.book) {
109
+ correct += 1;
110
+ found.add(key.statement);
111
+ }
112
+ else {
113
+ wrong += 1;
114
+ failures.push(Object.freeze({
115
+ statementIds: Object.freeze(match.statement.map((line) => line.id)),
116
+ bookIds: Object.freeze(match.book.map((line) => line.id)),
117
+ score: match.scored.score,
118
+ reason: expected === undefined ? "no truth for these lines" : "paired the wrong lines",
119
+ }));
120
+ }
121
+ }
122
+ let suggestedCorrect = 0;
123
+ let suggestedWrong = 0;
124
+ for (const match of result.suggested) {
125
+ const key = matchKey(match);
126
+ if (groups.get(key.statement) === key.book) {
127
+ suggestedCorrect += 1;
128
+ found.add(key.statement);
129
+ }
130
+ else {
131
+ suggestedWrong += 1;
132
+ }
133
+ }
134
+ const expected = groups.size;
135
+ const committed = correct + wrong;
136
+ const precision = committed === 0 ? 1 : correct / committed;
137
+ const recall = expected === 0 ? 1 : correct / expected;
138
+ const recallWithSuggestions = expected === 0 ? 1 : found.size / expected;
139
+ const f1 = precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
140
+ return Object.freeze({
141
+ correct,
142
+ wrong,
143
+ missed: expected - found.size,
144
+ suggestedCorrect,
145
+ suggestedWrong,
146
+ expected,
147
+ precision,
148
+ recall,
149
+ recallWithSuggestions,
150
+ f1,
151
+ failures: Object.freeze(failures),
152
+ });
153
+ }
154
+ const percent = (value) => `${(value * 100).toFixed(1)}%`;
155
+ export function renderAccuracy(report) {
156
+ return [
157
+ `expected ${report.expected} correct ${report.correct} wrong ${report.wrong} missed ${report.missed}`,
158
+ `suggested ${report.suggestedCorrect} right / ${report.suggestedWrong} wrong`,
159
+ `precision ${percent(report.precision)} recall ${percent(report.recall)}` +
160
+ ` recall incl. suggestions ${percent(report.recallWithSuggestions)} F1 ${report.f1.toFixed(3)}`,
161
+ ].join("\n");
162
+ }