@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/LICENSE +201 -0
- package/NOTICE +9 -0
- package/README.md +26 -5
- package/dist/index.js +75 -22
- package/dist/lib/allocate.d.ts +7 -0
- package/dist/lib/allocate.js +95 -55
- package/dist/lib/coding.d.ts +46 -4
- package/dist/lib/coding.js +221 -40
- package/dist/lib/controls.d.ts +5 -2
- package/dist/lib/controls.js +21 -14
- package/dist/lib/exact.d.ts +68 -0
- package/dist/lib/exact.js +122 -0
- package/dist/lib/lots.d.ts +7 -0
- package/dist/lib/lots.js +141 -45
- package/dist/lib/match.d.ts +5 -1
- package/dist/lib/match.js +122 -20
- package/dist/lib/statement.d.ts +44 -5
- package/dist/lib/statement.js +205 -106
- package/dist/lib/tax.d.ts +7 -1
- package/dist/lib/tax.js +60 -42
- package/dist/paths.d.ts +21 -2
- package/dist/paths.js +72 -13
- package/package.json +12 -7
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
|
33
|
-
const
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
79
|
-
const
|
|
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
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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 -
|
|
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:
|
|
122
|
-
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.
|
|
235
|
+
totalExposureMinor: sumExact(pairs.map((p) => p.exposureMinor), "the total exposure"),
|
|
134
236
|
threeWay: receiptLines.length > 0,
|
|
135
237
|
};
|
|
136
238
|
}
|
package/dist/lib/statement.d.ts
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
/**
|
|
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;
|