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,319 @@
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 { Money } from "../money/money.js";
20
+ import { daysBetween } from "../ledger/date.js";
21
+ import { similarityBreakdown } from "./similarity.js";
22
+ /**
23
+ * Amount and date carry most of the weight, and deliberately so. A bank
24
+ * descriptor and a narration are written by different people for different
25
+ * purposes; agreeing on the penny and the day is much stronger evidence than
26
+ * agreeing on the wording, and description is best used to break ties between
27
+ * candidates that already agree on the numbers.
28
+ */
29
+ export const DEFAULT_WEIGHTS = Object.freeze({
30
+ amount: 0.3,
31
+ date: 0.25,
32
+ description: 0.3,
33
+ reference: 0.15,
34
+ // Level with description, and for a reason that is worth stating: a reviewer
35
+ // confirming a counterparty is a person asserting the identity the
36
+ // description rule is guessing at, so it should not count for less than the
37
+ // guess. It is deliberately not higher. Memory is evidence about *who*, not
38
+ // about *which transaction* — two payments to the same supplier in one week
39
+ // are remembered equally well — so it lifts a pair the numbers already agree
40
+ // on over the auto-accept line and cannot carry one there on its own.
41
+ memory: 0.3,
42
+ });
43
+ export function resolveScoringOptions(options = {}) {
44
+ return Object.freeze({
45
+ dateWindowDays: options.dateWindowDays ?? 7,
46
+ amountToleranceMinorUnits: options.amountToleranceMinorUnits ?? 0n,
47
+ weights: Object.freeze({ ...DEFAULT_WEIGHTS, ...(options.weights ?? {}) }),
48
+ autoAcceptScore: options.autoAcceptScore ?? 0.86,
49
+ suggestScore: options.suggestScore ?? 0.45,
50
+ memory: options.memory,
51
+ });
52
+ }
53
+ const abs = (value) => (value < 0n ? -value : value);
54
+ function formatMinor(minorUnits, amount) {
55
+ return Money.ofMinor(minorUnits, amount.currency).toDecimalString();
56
+ }
57
+ /**
58
+ * Date proximity, decaying to zero at the edge of the window.
59
+ *
60
+ * The decay is quadratic rather than linear because settlement lag clusters
61
+ * hard at nought to two days: a three-day gap is much more suspicious than
62
+ * three times a one-day gap.
63
+ */
64
+ function dateScore(gap, windowDays) {
65
+ const distance = Math.abs(gap);
66
+ if (distance === 0)
67
+ return 1;
68
+ if (windowDays <= 0)
69
+ return 0;
70
+ const fraction = distance / windowDays;
71
+ if (fraction >= 1)
72
+ return 0;
73
+ return (1 - fraction) ** 2;
74
+ }
75
+ function reject(rule, detail, reasons, dayGap, amountGap) {
76
+ return Object.freeze({
77
+ score: 0,
78
+ confidence: "rejected",
79
+ reasons: Object.freeze([
80
+ ...reasons,
81
+ Object.freeze({ rule, detail, score: 0, weight: 0, contribution: 0 }),
82
+ ]),
83
+ rejectedBy: rule,
84
+ dayGap,
85
+ amountGap,
86
+ });
87
+ }
88
+ function scoreSides(sides, resolved) {
89
+ const reasons = [];
90
+ const sameCurrency = sides.bookAmount.sameCurrency(sides.statementAmount);
91
+ const amountGap = sameCurrency
92
+ ? sides.statementAmount.minorUnits - sides.bookAmount.minorUnits
93
+ : 0n;
94
+ const dayGap = daysBetween(sides.bookDate, sides.statementDate);
95
+ if (!sameCurrency) {
96
+ return reject("amount", `${sides.bookAmount.currency.code} in the books against ${sides.statementAmount.currency.code} on the statement`, reasons, dayGap, 0n);
97
+ }
98
+ // --- gates -------------------------------------------------------------
99
+ if (abs(amountGap) > resolved.amountToleranceMinorUnits) {
100
+ return reject("amount", `off by ${formatMinor(amountGap, sides.bookAmount)}`, reasons, dayGap, amountGap);
101
+ }
102
+ const bookSign = sides.bookAmount.sign;
103
+ const statementSign = sides.statementAmount.sign;
104
+ if (bookSign !== 0 && statementSign !== 0 && bookSign !== statementSign) {
105
+ return reject("direction", bookSign === 1 ? "money in against money out" : "money out against money in", reasons, dayGap, amountGap);
106
+ }
107
+ if (Math.abs(dayGap) > resolved.dateWindowDays) {
108
+ return reject("date", `${Math.abs(dayGap)} days apart, outside the ${resolved.dateWindowDays} day window`, reasons, dayGap, amountGap);
109
+ }
110
+ const tolerance = resolved.amountToleranceMinorUnits;
111
+ const amountRuleScore = amountGap === 0n || tolerance === 0n
112
+ ? 1
113
+ : 1 - Number(abs(amountGap)) / Number(tolerance) / 2;
114
+ reasons.push(Object.freeze({
115
+ rule: "amount",
116
+ detail: amountGap === 0n
117
+ ? `exact at ${sides.statementAmount.toDecimalString()}`
118
+ : `within tolerance, off by ${formatMinor(amountGap, sides.bookAmount)}`,
119
+ score: amountRuleScore,
120
+ weight: resolved.weights.amount,
121
+ contribution: amountRuleScore * resolved.weights.amount,
122
+ }));
123
+ // --- weighted rules ----------------------------------------------------
124
+ const dateRuleScore = dateScore(dayGap, resolved.dateWindowDays);
125
+ reasons.push(Object.freeze({
126
+ rule: "date",
127
+ detail: dayGap === 0
128
+ ? "same day"
129
+ : `${Math.abs(dayGap)} day${Math.abs(dayGap) === 1 ? "" : "s"} ${dayGap > 0 ? "later" : "earlier"} on the statement`,
130
+ score: dateRuleScore,
131
+ weight: resolved.weights.date,
132
+ contribution: dateRuleScore * resolved.weights.date,
133
+ }));
134
+ // Compared on the normalised forms: "DD RENT, AUGUST 08" and "August rent"
135
+ // are the same event, and only the normalised text makes that visible.
136
+ const similarity = similarityBreakdown(sides.bookNormalised, sides.statementNormalised);
137
+ reasons.push(Object.freeze({
138
+ rule: "description",
139
+ detail: similarity.sharedTokens.length > 0
140
+ ? `shares ${similarity.sharedTokens.join(", ")}`
141
+ : `little in common ("${sides.bookNormalised}" / "${sides.statementNormalised}")`,
142
+ score: similarity.score,
143
+ weight: resolved.weights.description,
144
+ contribution: similarity.score * resolved.weights.description,
145
+ }));
146
+ const bookReferenceText = `${sides.bookDescription} ${sides.bookReference ?? ""}`;
147
+ const statementReferenceText = `${sides.statementDescription} ${sides.statementReference ?? ""}`;
148
+ const shared = sharedReferenceList(bookReferenceText, statementReferenceText);
149
+ const eitherHasReference = hasReference(bookReferenceText) || hasReference(statementReferenceText);
150
+ if (eitherHasReference) {
151
+ const referenceScore = shared.length > 0 ? 1 : 0;
152
+ reasons.push(Object.freeze({
153
+ rule: "reference",
154
+ detail: shared.length > 0 ? `reference ${shared.join(", ")}` : "references disagree",
155
+ score: referenceScore,
156
+ weight: resolved.weights.reference,
157
+ contribution: referenceScore * resolved.weights.reference,
158
+ }));
159
+ }
160
+ else {
161
+ reasons.push(Object.freeze({
162
+ rule: "reference",
163
+ detail: "neither side carries a reference",
164
+ score: 0,
165
+ weight: 0,
166
+ contribution: 0,
167
+ }));
168
+ }
169
+ // --- what the reviewer already told us ---------------------------------
170
+ //
171
+ // Weighted only when there is something to say. A pair involving a
172
+ // counterparty nobody has ever ruled on should score exactly as it did before
173
+ // memory existed, so an absent memory contributes nothing and is not averaged
174
+ // in — the same treatment the reference rule gets when neither side carries
175
+ // one.
176
+ const memoryVerdict = resolved.memory?.recall(sides.statementDescription, sides.bookDescription);
177
+ const remembered = memoryVerdict !== undefined && memoryVerdict.kind !== "unknown";
178
+ reasons.push(Object.freeze({
179
+ rule: "memory",
180
+ detail: memoryVerdict?.detail ?? "nothing remembered about this counterparty",
181
+ score: memoryVerdict?.score ?? 0,
182
+ weight: remembered ? resolved.weights.memory : 0,
183
+ contribution: remembered ? (memoryVerdict?.score ?? 0) * resolved.weights.memory : 0,
184
+ }));
185
+ const totalWeight = reasons.reduce((sum, reason) => sum + reason.weight, 0);
186
+ const averaged = totalWeight === 0 ? 0 : reasons.reduce((sum, reason) => sum + reason.contribution, 0) / totalWeight;
187
+ const referenceHit = shared.length > 0;
188
+ // A reviewer who has already refused this pairing outranks the evidence that
189
+ // would otherwise call it exact. Without this the floor below would restore
190
+ // the score to 0.95 and the refusal would have changed nothing, which is the
191
+ // one outcome guaranteed to make somebody stop using the review queue.
192
+ const vetoed = memoryVerdict?.kind === "rejected" || memoryVerdict?.kind === "contradicted";
193
+ const exact = !vetoed && amountGap === 0n && dayGap === 0 && (referenceHit || similarity.score >= 0.9);
194
+ // Same penny, same day, and an external reference both sides agree on is
195
+ // about as good as reconciliation evidence gets. When all three line up the
196
+ // wording cannot drag the score down; a bank that writes "SQ *SETTLEMENT
197
+ // 0805" where we wrote "Processor settlement" has still told us enough.
198
+ const score = exact ? Math.max(averaged, 0.95) : averaged;
199
+ let confidence;
200
+ if (exact)
201
+ confidence = "exact";
202
+ else if (score >= resolved.autoAcceptScore)
203
+ confidence = "high";
204
+ else if (score >= (resolved.autoAcceptScore + resolved.suggestScore) / 2)
205
+ confidence = "medium";
206
+ else if (score >= resolved.suggestScore)
207
+ confidence = "low";
208
+ else
209
+ confidence = "rejected";
210
+ return Object.freeze({
211
+ score,
212
+ confidence,
213
+ reasons: Object.freeze(reasons),
214
+ rejectedBy: null,
215
+ dayGap,
216
+ amountGap,
217
+ });
218
+ }
219
+ function hasReference(text) {
220
+ return /(?:^|[^A-Za-z0-9])[A-Za-z]*\d{3,}/.test(text);
221
+ }
222
+ function sharedReferenceList(left, right) {
223
+ return similarityBreakdown(left, right).sharedReferences;
224
+ }
225
+ /** Score one ledger movement against one statement line. */
226
+ export function scorePair(book, line, options = {}) {
227
+ return scoreSides({
228
+ bookAmount: book.amount,
229
+ bookDate: book.date,
230
+ bookDescription: book.description,
231
+ bookNormalised: book.normalisedDescription,
232
+ bookReference: book.reference,
233
+ statementAmount: line.amount,
234
+ statementDate: line.date,
235
+ statementDescription: line.description,
236
+ statementNormalised: line.normalisedDescription,
237
+ statementReference: line.reference,
238
+ }, resolveScoringOptions(options));
239
+ }
240
+ /**
241
+ * Score a group: several ledger movements against several statement lines.
242
+ *
243
+ * The group is collapsed to its total, its earliest date, and its descriptions
244
+ * joined, then scored as though it were a single pair. Joining the text is the
245
+ * right move rather than a shortcut — a batch payment's bank descriptor often
246
+ * names one of the invoices in it, and the token-set similarity finds that
247
+ * inside the joined string.
248
+ */
249
+ export function scoreGroup(books, lines, options = {}) {
250
+ if (books.length === 0 || lines.length === 0) {
251
+ return Object.freeze({
252
+ score: 0,
253
+ confidence: "rejected",
254
+ reasons: Object.freeze([
255
+ Object.freeze({
256
+ rule: "amount",
257
+ detail: "a group needs at least one line on each side",
258
+ score: 0,
259
+ weight: 0,
260
+ contribution: 0,
261
+ }),
262
+ ]),
263
+ rejectedBy: "amount",
264
+ dayGap: 0,
265
+ amountGap: 0n,
266
+ });
267
+ }
268
+ const resolved = resolveScoringOptions(options);
269
+ const bookCurrency = books[0].amount.currency;
270
+ const lineCurrency = lines[0].amount.currency;
271
+ const bookTotal = books.reduce((sum, book) => sum + book.amount.minorUnits, 0n);
272
+ const lineTotal = lines.reduce((sum, line) => sum + line.amount.minorUnits, 0n);
273
+ const earliest = (values) => values.reduce((min, value) => (value < min ? value : min));
274
+ const scored = scoreSides({
275
+ bookAmount: Money.ofMinor(bookTotal, bookCurrency),
276
+ bookDate: earliest(books.map((b) => b.date)),
277
+ bookDescription: books.map((b) => b.description).join(" "),
278
+ bookNormalised: books.map((b) => b.normalisedDescription).join(" "),
279
+ bookReference: books.map((b) => b.reference).filter((r) => r !== null).join(" ") || null,
280
+ statementAmount: Money.ofMinor(lineTotal, lineCurrency),
281
+ statementDate: earliest(lines.map((l) => l.date)),
282
+ statementDescription: lines.map((l) => l.description).join(" "),
283
+ statementNormalised: lines.map((l) => l.normalisedDescription).join(" "),
284
+ statementReference: lines.map((l) => l.reference).filter((r) => r !== null).join(" ") || null,
285
+ }, resolved);
286
+ if (scored.rejectedBy !== null)
287
+ return scored;
288
+ // A group is a weaker claim than a pair — there are more ways to be
289
+ // accidentally right — so the wider it is, the more the score is discounted.
290
+ const width = books.length + lines.length;
291
+ const penalty = 1 - Math.min(0.18, 0.045 * (width - 2));
292
+ const score = scored.score * penalty;
293
+ const reasons = [
294
+ ...scored.reasons,
295
+ Object.freeze({
296
+ rule: "amount",
297
+ detail: `${books.length} ledger ${books.length === 1 ? "entry" : "entries"} against ${lines.length} statement ${lines.length === 1 ? "line" : "lines"}`,
298
+ score: penalty,
299
+ weight: 0,
300
+ contribution: 0,
301
+ }),
302
+ ];
303
+ let confidence = scored.confidence;
304
+ if (confidence === "exact")
305
+ confidence = "high";
306
+ if (score < resolved.suggestScore)
307
+ confidence = "rejected";
308
+ else if (score < (resolved.autoAcceptScore + resolved.suggestScore) / 2 && confidence !== "low") {
309
+ confidence = "low";
310
+ }
311
+ return Object.freeze({
312
+ score,
313
+ confidence,
314
+ reasons: Object.freeze(reasons),
315
+ rejectedBy: null,
316
+ dayGap: scored.dayGap,
317
+ amountGap: scored.amountGap,
318
+ });
319
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * How alike are two descriptions?
3
+ *
4
+ * The answer has to be graded, not boolean. "FPI ACME LTD — INV1001" and
5
+ * "Invoice 1001 settled" describe the same event and share almost no
6
+ * characters; "DD RENT, AUGUST 08" and "DD RENT, JULY 08" differ by one word
7
+ * and describe different events. A single distance metric gets both of these
8
+ * wrong, so three signals are combined:
9
+ *
10
+ * - character distance, which catches truncation and typos;
11
+ * - token overlap, which survives reordering and padding words;
12
+ * - reference tokens (`INV1001`, `SUB9931`), which are worth far more than an
13
+ * ordinary word because banks pass them through unchanged.
14
+ *
15
+ * Everything here is pure text. Nothing knows about money, dates or matching.
16
+ */
17
+ /** Split into comparable tokens: uppercase, alphanumeric, no runts. */
18
+ export declare function tokenise(text: string): string[];
19
+ /**
20
+ * Levenshtein edit distance, two-row DP.
21
+ *
22
+ * `maxDistance` turns it into a bounded check: once every cell in a row is
23
+ * over the ceiling no later row can come back under it, so the walk stops and
24
+ * returns `maxDistance + 1`. Callers comparing hundreds of pairs care.
25
+ */
26
+ export declare function levenshtein(a: string, b: string, maxDistance?: number): number;
27
+ /** Edit distance rescaled to `0..1`, where 1 is identical. */
28
+ export declare function levenshteinRatio(a: string, b: string): number;
29
+ /**
30
+ * Jaro similarity: transposition-aware, and much kinder than edit distance to
31
+ * strings that agree at the start and diverge later — which is how truncated
32
+ * bank descriptors fail.
33
+ */
34
+ export declare function jaro(a: string, b: string): number;
35
+ /**
36
+ * Jaro-Winkler: Jaro with a bonus for a shared prefix, capped at four
37
+ * characters. Bank descriptors put the merchant first, so the prefix carries
38
+ * more signal than the tail.
39
+ */
40
+ export declare function jaroWinkler(a: string, b: string, prefixWeight?: number): number;
41
+ /**
42
+ * A token that looks like an external reference rather than a word: it mixes
43
+ * letters and digits, or is a bare run of digits long enough to be an id.
44
+ */
45
+ export declare function isReferenceToken(token: string): boolean;
46
+ export declare function referenceTokens(text: string): string[];
47
+ /**
48
+ * References the two sides agree on.
49
+ *
50
+ * Equality is deliberately loose: `INV1001` on our side and `1001` on the
51
+ * bank's side are the same reference, and one being a suffix of the other is
52
+ * the normal shape of that disagreement. Digit runs shorter than three are
53
+ * ignored — too many false hits.
54
+ */
55
+ export declare function sharedReferences(left: string, right: string): string[];
56
+ /**
57
+ * Weighted Sørensen-Dice over token sets.
58
+ *
59
+ * Weighting by token length is what stops "LTD" counting for as much as
60
+ * "TOOLCHAIN". Tokens pair up greedily on Jaro-Winkler above a cutoff, so
61
+ * near-misses ("SALARIES" / "SALARY") land as a partial hit rather than
62
+ * nothing, and each token can only be spent once — otherwise a description
63
+ * that says "RENT RENT" would look like a perfect match for "RENT".
64
+ *
65
+ * Dividing by the weight of *both* sides rather than the shorter one is what
66
+ * makes the result symmetric, and it means a two-word descriptor buried inside
67
+ * a ten-word one scores as the partial agreement it is.
68
+ */
69
+ export declare function tokenOverlap(left: readonly string[], right: readonly string[]): number;
70
+ export interface SimilarityBreakdown {
71
+ /** Combined score in `0..1`. */
72
+ readonly score: number;
73
+ readonly tokenScore: number;
74
+ readonly characterScore: number;
75
+ readonly sharedReferences: readonly string[];
76
+ readonly sharedTokens: readonly string[];
77
+ }
78
+ /**
79
+ * Full similarity with the working shown, so a reviewer can see which signal
80
+ * carried the match.
81
+ *
82
+ * A shared reference is treated as near-decisive: it lifts the floor to 0.9
83
+ * rather than being averaged away by two descriptions that otherwise share no
84
+ * words at all, which is exactly the "Invoice 1001 settled" case.
85
+ */
86
+ export declare function similarityBreakdown(left: string, right: string): SimilarityBreakdown;
87
+ /** The combined score alone. */
88
+ export declare function descriptionSimilarity(left: string, right: string): number;
@@ -0,0 +1,272 @@
1
+ /**
2
+ * How alike are two descriptions?
3
+ *
4
+ * The answer has to be graded, not boolean. "FPI ACME LTD — INV1001" and
5
+ * "Invoice 1001 settled" describe the same event and share almost no
6
+ * characters; "DD RENT, AUGUST 08" and "DD RENT, JULY 08" differ by one word
7
+ * and describe different events. A single distance metric gets both of these
8
+ * wrong, so three signals are combined:
9
+ *
10
+ * - character distance, which catches truncation and typos;
11
+ * - token overlap, which survives reordering and padding words;
12
+ * - reference tokens (`INV1001`, `SUB9931`), which are worth far more than an
13
+ * ordinary word because banks pass them through unchanged.
14
+ *
15
+ * Everything here is pure text. Nothing knows about money, dates or matching.
16
+ */
17
+ /** Split into comparable tokens: uppercase, alphanumeric, no runts. */
18
+ export function tokenise(text) {
19
+ return text
20
+ .toUpperCase()
21
+ .split(/[^A-Z0-9]+/)
22
+ .filter((token) => token.length > 0);
23
+ }
24
+ /**
25
+ * Levenshtein edit distance, two-row DP.
26
+ *
27
+ * `maxDistance` turns it into a bounded check: once every cell in a row is
28
+ * over the ceiling no later row can come back under it, so the walk stops and
29
+ * returns `maxDistance + 1`. Callers comparing hundreds of pairs care.
30
+ */
31
+ export function levenshtein(a, b, maxDistance) {
32
+ if (a === b)
33
+ return 0;
34
+ // The ceiling is applied before the trivial cases, so a caller asking "is
35
+ // this within 2?" gets the same answer shape however the two strings differ.
36
+ const ceiling = maxDistance ?? Number.POSITIVE_INFINITY;
37
+ if (Math.abs(a.length - b.length) > ceiling)
38
+ return Math.floor(ceiling) + 1;
39
+ if (a.length === 0)
40
+ return b.length;
41
+ if (b.length === 0)
42
+ return a.length;
43
+ // Iterate over the shorter string in the inner loop.
44
+ const [short, long] = a.length <= b.length ? [a, b] : [b, a];
45
+ let previous = new Array(short.length + 1);
46
+ let current = new Array(short.length + 1);
47
+ for (let j = 0; j <= short.length; j++)
48
+ previous[j] = j;
49
+ for (let i = 1; i <= long.length; i++) {
50
+ current[0] = i;
51
+ let rowMinimum = i;
52
+ const longChar = long.charCodeAt(i - 1);
53
+ for (let j = 1; j <= short.length; j++) {
54
+ const substitution = previous[j - 1] + (longChar === short.charCodeAt(j - 1) ? 0 : 1);
55
+ const deletion = previous[j] + 1;
56
+ const insertion = current[j - 1] + 1;
57
+ const best = Math.min(substitution, deletion, insertion);
58
+ current[j] = best;
59
+ if (best < rowMinimum)
60
+ rowMinimum = best;
61
+ }
62
+ if (rowMinimum > ceiling)
63
+ return Math.floor(ceiling) + 1;
64
+ const swap = previous;
65
+ previous = current;
66
+ current = swap;
67
+ }
68
+ return previous[short.length];
69
+ }
70
+ /** Edit distance rescaled to `0..1`, where 1 is identical. */
71
+ export function levenshteinRatio(a, b) {
72
+ const longest = Math.max(a.length, b.length);
73
+ if (longest === 0)
74
+ return 1;
75
+ return 1 - levenshtein(a, b) / longest;
76
+ }
77
+ /**
78
+ * Jaro similarity: transposition-aware, and much kinder than edit distance to
79
+ * strings that agree at the start and diverge later — which is how truncated
80
+ * bank descriptors fail.
81
+ */
82
+ export function jaro(a, b) {
83
+ if (a === b)
84
+ return 1;
85
+ if (a.length === 0 || b.length === 0)
86
+ return 0;
87
+ const window = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
88
+ const aMatched = new Array(a.length).fill(false);
89
+ const bMatched = new Array(b.length).fill(false);
90
+ let matches = 0;
91
+ for (let i = 0; i < a.length; i++) {
92
+ const from = Math.max(0, i - window);
93
+ const to = Math.min(i + window + 1, b.length);
94
+ for (let j = from; j < to; j++) {
95
+ if (bMatched[j] === true)
96
+ continue;
97
+ if (a[i] !== b[j])
98
+ continue;
99
+ aMatched[i] = true;
100
+ bMatched[j] = true;
101
+ matches++;
102
+ break;
103
+ }
104
+ }
105
+ if (matches === 0)
106
+ return 0;
107
+ let transpositions = 0;
108
+ let k = 0;
109
+ for (let i = 0; i < a.length; i++) {
110
+ if (aMatched[i] !== true)
111
+ continue;
112
+ while (bMatched[k] !== true)
113
+ k++;
114
+ if (a[i] !== b[k])
115
+ transpositions++;
116
+ k++;
117
+ }
118
+ const half = transpositions / 2;
119
+ return (matches / a.length + matches / b.length + (matches - half) / matches) / 3;
120
+ }
121
+ /**
122
+ * Jaro-Winkler: Jaro with a bonus for a shared prefix, capped at four
123
+ * characters. Bank descriptors put the merchant first, so the prefix carries
124
+ * more signal than the tail.
125
+ */
126
+ export function jaroWinkler(a, b, prefixWeight = 0.1) {
127
+ const base = jaro(a, b);
128
+ if (base === 0)
129
+ return 0;
130
+ let prefix = 0;
131
+ const limit = Math.min(4, a.length, b.length);
132
+ while (prefix < limit && a[prefix] === b[prefix])
133
+ prefix++;
134
+ return base + prefix * prefixWeight * (1 - base);
135
+ }
136
+ /**
137
+ * A token that looks like an external reference rather than a word: it mixes
138
+ * letters and digits, or is a bare run of digits long enough to be an id.
139
+ */
140
+ export function isReferenceToken(token) {
141
+ if (token.length < 3)
142
+ return false;
143
+ const hasDigit = /[0-9]/.test(token);
144
+ if (!hasDigit)
145
+ return false;
146
+ const hasLetter = /[A-Z]/.test(token);
147
+ if (hasLetter)
148
+ return true;
149
+ return token.length >= 4;
150
+ }
151
+ export function referenceTokens(text) {
152
+ return tokenise(text).filter(isReferenceToken);
153
+ }
154
+ /** The digits inside a reference, which is the part banks preserve. */
155
+ function digitsOf(token) {
156
+ return token.replace(/[^0-9]/g, "");
157
+ }
158
+ /**
159
+ * References the two sides agree on.
160
+ *
161
+ * Equality is deliberately loose: `INV1001` on our side and `1001` on the
162
+ * bank's side are the same reference, and one being a suffix of the other is
163
+ * the normal shape of that disagreement. Digit runs shorter than three are
164
+ * ignored — too many false hits.
165
+ */
166
+ export function sharedReferences(left, right) {
167
+ const leftRefs = referenceTokens(left);
168
+ const rightRefs = referenceTokens(right);
169
+ if (leftRefs.length === 0 || rightRefs.length === 0)
170
+ return [];
171
+ const shared = [];
172
+ for (const l of leftRefs) {
173
+ for (const r of rightRefs) {
174
+ if (l === r) {
175
+ shared.push(l);
176
+ continue;
177
+ }
178
+ const ld = digitsOf(l);
179
+ const rd = digitsOf(r);
180
+ if (ld.length >= 3 && rd.length >= 3 && (ld.endsWith(rd) || rd.endsWith(ld))) {
181
+ // Report the barer of the two spellings, so the same agreement is
182
+ // named the same way whichever side it is read from.
183
+ shared.push(l.length <= r.length ? l : r);
184
+ }
185
+ }
186
+ }
187
+ return [...new Set(shared)].sort();
188
+ }
189
+ const tokenWeight = (token) => Math.min(token.length, 12);
190
+ /**
191
+ * Weighted Sørensen-Dice over token sets.
192
+ *
193
+ * Weighting by token length is what stops "LTD" counting for as much as
194
+ * "TOOLCHAIN". Tokens pair up greedily on Jaro-Winkler above a cutoff, so
195
+ * near-misses ("SALARIES" / "SALARY") land as a partial hit rather than
196
+ * nothing, and each token can only be spent once — otherwise a description
197
+ * that says "RENT RENT" would look like a perfect match for "RENT".
198
+ *
199
+ * Dividing by the weight of *both* sides rather than the shorter one is what
200
+ * makes the result symmetric, and it means a two-word descriptor buried inside
201
+ * a ten-word one scores as the partial agreement it is.
202
+ */
203
+ export function tokenOverlap(left, right) {
204
+ if (left.length === 0 || right.length === 0)
205
+ return 0;
206
+ // Iterate the shorter side, breaking ties canonically so that swapping the
207
+ // arguments cannot change the greedy pairing.
208
+ const leftFirst = left.length < right.length ||
209
+ (left.length === right.length && left.join(" ") <= right.join(" "));
210
+ const [shorter, longer] = leftFirst ? [left, right] : [right, left];
211
+ const taken = new Array(longer.length).fill(false);
212
+ let matched = 0;
213
+ for (const token of shorter) {
214
+ let bestScore = 0;
215
+ let bestIndex = -1;
216
+ for (let i = 0; i < longer.length; i++) {
217
+ if (taken[i] === true)
218
+ continue;
219
+ const score = jaroWinkler(token, longer[i]);
220
+ if (score > bestScore) {
221
+ bestScore = score;
222
+ bestIndex = i;
223
+ }
224
+ }
225
+ if (bestIndex >= 0 && bestScore >= 0.82) {
226
+ taken[bestIndex] = true;
227
+ matched += Math.min(tokenWeight(token), tokenWeight(longer[bestIndex])) * bestScore;
228
+ }
229
+ }
230
+ const total = left.reduce((sum, t) => sum + tokenWeight(t), 0) +
231
+ right.reduce((sum, t) => sum + tokenWeight(t), 0);
232
+ return total === 0 ? 0 : Math.min(1, (2 * matched) / total);
233
+ }
234
+ /**
235
+ * Full similarity with the working shown, so a reviewer can see which signal
236
+ * carried the match.
237
+ *
238
+ * A shared reference is treated as near-decisive: it lifts the floor to 0.9
239
+ * rather than being averaged away by two descriptions that otherwise share no
240
+ * words at all, which is exactly the "Invoice 1001 settled" case.
241
+ */
242
+ export function similarityBreakdown(left, right) {
243
+ const leftTokens = tokenise(left);
244
+ const rightTokens = tokenise(right);
245
+ if (leftTokens.length === 0 || rightTokens.length === 0) {
246
+ return Object.freeze({
247
+ score: 0,
248
+ tokenScore: 0,
249
+ characterScore: 0,
250
+ sharedReferences: Object.freeze([]),
251
+ sharedTokens: Object.freeze([]),
252
+ });
253
+ }
254
+ const tokenScore = tokenOverlap(leftTokens, rightTokens);
255
+ const characterScore = jaroWinkler(leftTokens.join(" "), rightTokens.join(" "));
256
+ const refs = sharedReferences(left, right);
257
+ const shared = leftTokens.filter((t) => rightTokens.includes(t));
258
+ let score = 0.65 * tokenScore + 0.35 * characterScore;
259
+ if (refs.length > 0)
260
+ score = Math.max(score, 0.9);
261
+ return Object.freeze({
262
+ score: Math.min(1, score),
263
+ tokenScore,
264
+ characterScore,
265
+ sharedReferences: Object.freeze(refs),
266
+ sharedTokens: Object.freeze([...new Set(shared)].sort()),
267
+ });
268
+ }
269
+ /** The combined score alone. */
270
+ export function descriptionSimilarity(left, right) {
271
+ return similarityBreakdown(left, right).score;
272
+ }