@crewhaus/tool-money 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/lib/match.js CHANGED
@@ -7,6 +7,7 @@
7
7
  * 4,800 matches 4,750 is being paid to do subtraction, and it will sometimes
8
8
  * get it wrong on an invoice nobody re-reads.
9
9
  */
10
+ import { addDecimal, addExact, atScale, big, compareDecimal, decimalNumber, decimalOf, roundHalfAwayFromZero, sumExact, toNumber, } from "./exact";
10
11
  export const MATCH_STATUSES = [
11
12
  "matched",
12
13
  "price-variance",
@@ -23,14 +24,86 @@ function normalizeDescription(text) {
23
24
  .replace(/\s+/g, " ")
24
25
  .trim();
25
26
  }
26
- function withinTolerance(actual, expected, percentBps, absolute) {
27
- const delta = Math.abs(actual - expected);
28
- if (delta === 0)
27
+ const abs = (v) => (v < 0n ? -v : v);
28
+ /** `make()`, worked out on the first call and kept. */
29
+ function once(make) {
30
+ const held = {};
31
+ return () => {
32
+ if (held.made === undefined)
33
+ held.made = { value: make() };
34
+ return held.made.value;
35
+ };
36
+ }
37
+ /** Every line with a key, grouped by it; a line without one is in no group. */
38
+ function linesBy(lines, keyOf) {
39
+ const groups = new Map();
40
+ for (const line of lines) {
41
+ const key = keyOf(line);
42
+ if (key === undefined)
43
+ continue;
44
+ const group = groups.get(key);
45
+ if (group === undefined)
46
+ groups.set(key, { lines: [line], head: 0 });
47
+ else
48
+ group.lines.push(line);
49
+ }
50
+ return groups;
51
+ }
52
+ /**
53
+ * The first line of the order carrying `key` that is still unmatched: what
54
+ * searching the remaining lines in order finds. A matched line never comes
55
+ * back, so the ones at the head of the group are passed over once and for
56
+ * good.
57
+ */
58
+ function firstRemaining(groups, key, remaining) {
59
+ const group = groups.get(key);
60
+ if (group === undefined)
61
+ return undefined;
62
+ while (group.head < group.lines.length) {
63
+ const line = group.lines[group.head];
64
+ if (remaining.has(line.id))
65
+ return line;
66
+ group.head += 1;
67
+ }
68
+ return undefined;
69
+ }
70
+ /**
71
+ * Whether `actual` is within tolerance of `expected`, decided exactly.
72
+ *
73
+ * Prices are integers whose products with a rate pass 2^53 on ordinary
74
+ * amounts, and quantities are decimals a double cannot hold (0.33 against
75
+ * 0.3 at 10% is exactly on the bound, and 0.7.0's float arithmetic put it
76
+ * just outside). So every figure — the two values, the rate and the absolute
77
+ * bound — is compared as the exact decimal it was written as.
78
+ */
79
+ function withinTolerance(actual, expected, percentBps, absolute, what) {
80
+ if (compareDecimal(actual, expected) === 0)
29
81
  return true;
30
82
  // Either bound may pass. A percentage alone is useless on a cheap line and
31
83
  // an absolute alone is useless on an expensive one.
32
- const byPercent = percentBps !== undefined && Math.abs(expected) * percentBps >= delta * 10_000;
33
- const byAbsolute = absolute !== undefined && delta <= absolute;
84
+ const scale = Math.max(actual.scale, expected.scale);
85
+ const delta = abs(atScale(actual, scale) - atScale(expected, scale));
86
+ const base = abs(atScale(expected, scale));
87
+ let byPercent = false;
88
+ if (percentBps !== undefined && !Number.isNaN(percentBps)) {
89
+ if (percentBps === Number.POSITIVE_INFINITY)
90
+ byPercent = true;
91
+ else {
92
+ // |expected| × bps / 10 000 >= delta, on one scale: bps is b / 10^s.
93
+ const bps = decimalOf(percentBps, `${what} percentage tolerance`);
94
+ byPercent = base * bps.units >= delta * 10000n * 10n ** BigInt(bps.scale);
95
+ }
96
+ }
97
+ let byAbsolute = false;
98
+ if (absolute !== undefined && !Number.isNaN(absolute)) {
99
+ if (absolute === Number.POSITIVE_INFINITY)
100
+ byAbsolute = true;
101
+ else {
102
+ // delta / 10^scale <= a / 10^s.
103
+ const bound = decimalOf(absolute, `${what} absolute tolerance`);
104
+ byAbsolute = delta * 10n ** BigInt(bound.scale) <= bound.units * 10n ** BigInt(scale);
105
+ }
106
+ }
34
107
  return byPercent || byAbsolute;
35
108
  }
36
109
  export function matchInvoiceToPurchaseOrder(invoiceLines, poLines, receiptLines = [], tolerance = {}) {
@@ -45,11 +118,21 @@ export function matchInvoiceToPurchaseOrder(invoiceLines, poLines, receiptLines
45
118
  seen.add(line.id);
46
119
  }
47
120
  }
121
+ // Receipts against one line are summed exactly: 0.1 + 0.2 received is 0.3,
122
+ // not a float a hair over it that an invoice for 0.3 is then "within".
48
123
  const received = new Map();
49
124
  for (const entry of receiptLines) {
50
- received.set(entry.poLineId, (received.get(entry.poLineId) ?? 0) + entry.quantity);
125
+ const quantity = decimalOf(entry.quantity, `receipt for "${entry.poLineId}" quantity`);
126
+ const before = received.get(entry.poLineId);
127
+ received.set(entry.poLineId, before === undefined ? quantity : addDecimal(before, quantity));
51
128
  }
52
129
  const remainingPo = new Map(poLines.map((l) => [l.id, l]));
130
+ // The order lines by SKU and by normalized description, each built once,
131
+ // the first time it is needed. Searching every remaining line for each
132
+ // invoice line, and normalizing every description again, cost the invoice
133
+ // lines times the order lines.
134
+ const bySku = once(() => linesBy(poLines, (l) => l.sku));
135
+ const byDescription = once(() => linesBy(poLines, (l) => l.description === undefined ? undefined : normalizeDescription(l.description)));
53
136
  const pairs = [];
54
137
  const unmatchedInvoiceLines = [];
55
138
  for (const invoice of invoiceLines) {
@@ -59,13 +142,13 @@ export function matchInvoiceToPurchaseOrder(invoiceLines, poLines, receiptLines
59
142
  let po = invoice.poLineId === undefined ? undefined : remainingPo.get(invoice.poLineId);
60
143
  let matchedBy = "reference";
61
144
  if (!po && invoice.sku !== undefined) {
62
- po = [...remainingPo.values()].find((l) => l.sku !== undefined && l.sku === invoice.sku);
145
+ po = firstRemaining(bySku(), invoice.sku, remainingPo);
63
146
  if (po)
64
147
  matchedBy = "sku";
65
148
  }
66
149
  if (!po && invoice.description !== undefined) {
67
150
  const wanted = normalizeDescription(invoice.description);
68
- po = [...remainingPo.values()].find((l) => l.description !== undefined && normalizeDescription(l.description) === wanted);
151
+ po = firstRemaining(byDescription(), wanted, remainingPo);
69
152
  if (po)
70
153
  matchedBy = "description";
71
154
  }
@@ -75,15 +158,23 @@ export function matchInvoiceToPurchaseOrder(invoiceLines, poLines, receiptLines
75
158
  }
76
159
  remainingPo.delete(po.id);
77
160
  const reasons = [];
78
- const priceOk = withinTolerance(invoice.unitPriceMinor, po.unitPriceMinor, tolerance.pricePercentBps, tolerance.priceAbsoluteMinor);
79
- const quantityOk = withinTolerance(invoice.quantity, po.quantity, tolerance.quantityPercentBps, tolerance.quantityAbsolute);
161
+ const line = `invoice line "${invoice.id}"`;
162
+ const invoicePrice = big(invoice.unitPriceMinor, `${line} unitPriceMinor`);
163
+ const poPrice = big(po.unitPriceMinor, `order line "${po.id}" unitPriceMinor`);
164
+ const invoiceQuantity = decimalOf(invoice.quantity, `${line} quantity`);
165
+ const poQuantity = decimalOf(po.quantity, `order line "${po.id}" quantity`);
166
+ const priceOk = withinTolerance({ units: invoicePrice, scale: 0 }, { units: poPrice, scale: 0 }, tolerance.pricePercentBps, tolerance.priceAbsoluteMinor, `${line} price`);
167
+ const quantityOk = withinTolerance(invoiceQuantity, poQuantity, tolerance.quantityPercentBps, tolerance.quantityAbsolute, `${line} quantity`);
80
168
  if (!priceOk) {
81
169
  reasons.push(`unit price ${invoice.unitPriceMinor} against ${po.unitPriceMinor} on the order`);
82
170
  }
83
171
  if (!quantityOk) {
84
172
  reasons.push(`quantity ${invoice.quantity} against ${po.quantity} on the order`);
85
173
  }
86
- const receivedQuantity = received.has(po.id) ? received.get(po.id) : null;
174
+ const receivedExact = received.get(po.id);
175
+ const receivedQuantity = receivedExact === undefined
176
+ ? null
177
+ : decimalNumber(receivedExact, `the quantity received against order line "${po.id}"`);
87
178
  let status = !priceOk && !quantityOk
88
179
  ? "both-variance"
89
180
  : !priceOk
@@ -95,18 +186,29 @@ export function matchInvoiceToPurchaseOrder(invoiceLines, poLines, receiptLines
95
186
  // failure this control exists to catch, and it is invisible in a two-way
96
187
  // match however well price and quantity agree with the order.
97
188
  if (receiptLines.length > 0) {
98
- if (receivedQuantity === null || receivedQuantity === 0) {
189
+ if (receivedExact === undefined || receivedExact.units === 0n) {
99
190
  status = "not-received";
100
191
  reasons.push("nothing has been received against this order line");
101
192
  }
102
- else if (invoice.quantity > receivedQuantity) {
193
+ else if (compareDecimal(invoiceQuantity, receivedExact) > 0) {
103
194
  status = "over-receipt";
104
195
  reasons.push(`invoiced ${invoice.quantity} but only ${receivedQuantity} received`);
105
196
  }
106
197
  }
107
- const billable = Math.min(invoice.quantity, receivedQuantity ?? invoice.quantity);
108
- const exposureMinor = invoice.quantity * invoice.unitPriceMinor -
109
- billable * Math.min(invoice.unitPriceMinor, po.unitPriceMinor);
198
+ // What the variance costs: the invoice's quantity at its price, less what
199
+ // was billable (no more than arrived) at the lower of the two prices.
200
+ // Counted exactly — decimal quantities times integer prices, as bigints
201
+ // on one decimal scale — then rounded to whole minor units, a half away
202
+ // from zero, and refused past 2^53 rather than reported units off.
203
+ const billable = receivedExact !== undefined && compareDecimal(receivedExact, invoiceQuantity) < 0
204
+ ? receivedExact
205
+ : invoiceQuantity;
206
+ const scale = Math.max(invoiceQuantity.scale, billable.scale);
207
+ const lowerPrice = invoicePrice < poPrice ? invoicePrice : poPrice;
208
+ const exposureMinor = status === "matched"
209
+ ? 0
210
+ : toNumber(roundHalfAwayFromZero(atScale(invoiceQuantity, scale) * invoicePrice -
211
+ atScale(billable, scale) * lowerPrice, 10n ** BigInt(scale)), `${line} exposure`);
110
212
  pairs.push({
111
213
  invoiceLineId: invoice.id,
112
214
  poLineId: po.id,
@@ -114,12 +216,12 @@ export function matchInvoiceToPurchaseOrder(invoiceLines, poLines, receiptLines
114
216
  status,
115
217
  invoiceUnitPriceMinor: invoice.unitPriceMinor,
116
218
  poUnitPriceMinor: po.unitPriceMinor,
117
- priceDeltaMinor: invoice.unitPriceMinor - po.unitPriceMinor,
219
+ priceDeltaMinor: addExact(invoice.unitPriceMinor, -po.unitPriceMinor, `${line} price delta`),
118
220
  invoiceQuantity: invoice.quantity,
119
221
  poQuantity: po.quantity,
120
222
  receivedQuantity,
121
- quantityDelta: invoice.quantity - po.quantity,
122
- exposureMinor: status === "matched" ? 0 : exposureMinor,
223
+ quantityDelta: decimalNumber(addDecimal(invoiceQuantity, poQuantity, -1n), `${line} quantity delta`),
224
+ exposureMinor,
123
225
  reasons,
124
226
  });
125
227
  }
@@ -130,7 +232,7 @@ export function matchInvoiceToPurchaseOrder(invoiceLines, poLines, receiptLines
130
232
  unmatchedPoLines: [...remainingPo.keys()],
131
233
  ok: exceptions === 0 && unmatchedInvoiceLines.length === 0,
132
234
  exceptions,
133
- totalExposureMinor: pairs.reduce((s, p) => s + p.exposureMinor, 0),
235
+ totalExposureMinor: sumExact(pairs.map((p) => p.exposureMinor), "the total exposure"),
134
236
  threeWay: receiptLines.length > 0,
135
237
  };
136
238
  }
@@ -49,24 +49,51 @@ export type ParseOptions = {
49
49
  };
50
50
  /** Currency's minor-unit exponent. 2 for most, 0 for JPY. */
51
51
  readonly decimals?: number;
52
+ /**
53
+ * How many transactions and how many rejected rows to hold. The file is
54
+ * read to the end either way — `count`, `rejectedCount` and the totals
55
+ * cover every row — but the lists stop growing, so a caller that shows the
56
+ * first few hundred rows does not hold a million in memory first. Default:
57
+ * all of them.
58
+ */
59
+ readonly keep?: {
60
+ readonly transactions?: number;
61
+ readonly rejected?: number;
62
+ };
52
63
  };
53
64
  export type ParseResult = {
54
65
  readonly format: StatementFormat;
66
+ /** The transactions read, in file order — the first `keep.transactions` of them. */
55
67
  readonly transactions: ReadonlyArray<Transaction>;
68
+ /** Every transaction read, held or not. */
56
69
  readonly count: number;
57
- readonly totalMinor: number;
58
- readonly debitMinor: number;
59
- readonly creditMinor: number;
70
+ /**
71
+ * The sums of every transaction read, exact, or null when one comes to more
72
+ * than a JSON number holds exactly — `totalsUnavailable` then says which.
73
+ */
74
+ readonly totalMinor: number | null;
75
+ readonly debitMinor: number | null;
76
+ readonly creditMinor: number | null;
77
+ readonly totalsUnavailable: string | null;
60
78
  readonly dateOrder: DateOrder;
61
- /** Rows the file contained that could not be read, with the reason. */
79
+ /** Rows the file contained that could not be read, with the reason — the first `keep.rejected`. */
62
80
  readonly rejected: ReadonlyArray<{
63
81
  readonly row: number;
64
82
  readonly reason: string;
65
83
  }>;
84
+ /** Every row that could not be read, held or not. */
85
+ readonly rejectedCount: number;
66
86
  };
67
87
  /** RFC 4180 fields: quotes, doubled quotes inside them, embedded newlines. */
68
88
  export declare function parseCsvRows(text: string): string[][];
69
- /** Parse a money string to minor units, exactly, without floating point. */
89
+ /**
90
+ * Parse a money string to minor units, exactly, without floating point.
91
+ *
92
+ * Returns null for text that is not an amount. An amount past 2^53 − 1 minor
93
+ * units is refused with an {@link InexactAmountError}: a JSON number past
94
+ * that has already been rounded, and 90,071,992,547,409.93 read as
95
+ * 9007199254740992 cents is a figure one cent out that nothing flags.
96
+ */
70
97
  export declare function parseMoneyMinor(raw: string, decimals: number, decimalComma: boolean): number | null;
71
98
  /**
72
99
  * Parse a date, refusing what cannot be read unambiguously.
@@ -77,4 +104,16 @@ export declare function parseMoneyMinor(raw: string, decimals: number, decimalCo
77
104
  export declare function parseDate(raw: string, order: DateOrder): string | null;
78
105
  /** True when every slashed date in the file reads the same either way. */
79
106
  export declare function datesAreUnambiguous(values: ReadonlyArray<string>): boolean;
107
+ /**
108
+ * The `<STMTTRN>` blocks of an OFX file, in one forward pass over its tags.
109
+ *
110
+ * OFX 1.x is SGML, where an end tag may be left out, so a block that is
111
+ * never closed ends where the next one starts, at the transaction list's
112
+ * own tags, or at the end of the file — and is read like any other rather
113
+ * than dropped. (A lazy `<STMTTRN>…</STMTTRN>` match instead rescanned to
114
+ * the end of the file from every unclosed tag, which was quadratic, and
115
+ * swallowed the next transaction into an unclosed one.) A stray end tag is
116
+ * ignored.
117
+ */
118
+ export declare function ofxTransactionBlocks(text: string): Generator<string>;
80
119
  export declare function parseStatement(text: string, options?: ParseOptions): ParseResult;