@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/controls.js
CHANGED
|
@@ -2,14 +2,18 @@
|
|
|
2
2
|
* Spending controls and refund-abuse signals.
|
|
3
3
|
*
|
|
4
4
|
* An unattended harness that can move money needs a limit it cannot talk
|
|
5
|
-
* itself past
|
|
6
|
-
*
|
|
5
|
+
* itself past, and a function cannot be that limit on its own: it computes
|
|
6
|
+
* over the history, limits and clock it is handed. When a model hands them
|
|
7
|
+
* over, the verdict is advisory. It is a control only where those inputs come
|
|
8
|
+
* from a record the model cannot edit, and a limit that must hold belongs in
|
|
9
|
+
* the policy of whatever actually moves the money.
|
|
7
10
|
*
|
|
8
11
|
* Both functions take the history as an argument and the clock as a
|
|
9
12
|
* parameter. Nothing here reads a database or the time, so the same facts
|
|
10
13
|
* always give the same verdict — which is what makes a refusal explicable
|
|
11
14
|
* afterwards.
|
|
12
15
|
*/
|
|
16
|
+
import { addExact, mathRound, sumExact, toNumber } from "./exact";
|
|
13
17
|
function instant(value, what) {
|
|
14
18
|
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())) {
|
|
15
19
|
throw new Error(`${what} ("${value}") has no UTC offset`);
|
|
@@ -25,21 +29,21 @@ const WEEK = 7 * DAY;
|
|
|
25
29
|
export function checkSpendLimit(proposed, history, limits, nowMs) {
|
|
26
30
|
const entries = history.map((s) => ({ ...s, atMs: instant(s.at, `spend "${s.id}" at`) }));
|
|
27
31
|
const since = (ms) => entries.filter((e) => nowMs - e.atMs < ms && e.atMs <= nowMs);
|
|
28
|
-
const sum = (list) => list.
|
|
32
|
+
const sum = (list, what) => sumExact(list.map((e) => e.amountMinor), what);
|
|
29
33
|
const hour = since(HOUR);
|
|
30
34
|
const day = since(DAY);
|
|
31
35
|
const week = since(WEEK);
|
|
32
36
|
const windows = {
|
|
33
|
-
hourMinor: sum(hour),
|
|
34
|
-
dayMinor: sum(day),
|
|
35
|
-
weekMinor: sum(week),
|
|
37
|
+
hourMinor: sum(hour, "the spend in the last hour"),
|
|
38
|
+
dayMinor: sum(day, "the spend in the last day"),
|
|
39
|
+
weekMinor: sum(week, "the spend in the last week"),
|
|
36
40
|
hourCount: hour.length,
|
|
37
41
|
};
|
|
38
42
|
const violations = [];
|
|
39
43
|
const check = (name, cap, spent) => {
|
|
40
44
|
if (cap === undefined)
|
|
41
45
|
return;
|
|
42
|
-
const wouldBe = spent
|
|
46
|
+
const wouldBe = addExact(spent, proposed.amountMinor, `the ${name} spend with this payment`);
|
|
43
47
|
if (wouldBe > cap) {
|
|
44
48
|
violations.push({
|
|
45
49
|
limit: name,
|
|
@@ -62,7 +66,7 @@ export function checkSpendLimit(proposed, history, limits, nowMs) {
|
|
|
62
66
|
check("perDay", limits.perDayMinor, windows.dayMinor);
|
|
63
67
|
check("perWeek", limits.perWeekMinor, windows.weekMinor);
|
|
64
68
|
if (limits.perCounterpartyPerDayMinor !== undefined && proposed.counterparty !== undefined) {
|
|
65
|
-
const spent = sum(day.filter((e) => e.counterparty === proposed.counterparty));
|
|
69
|
+
const spent = sum(day.filter((e) => e.counterparty === proposed.counterparty), "the spend to this counterparty in the last day");
|
|
66
70
|
check(`perCounterpartyPerDay:${proposed.counterparty}`, limits.perCounterpartyPerDayMinor, spent);
|
|
67
71
|
}
|
|
68
72
|
if (limits.maxTransactionsPerHour !== undefined &&
|
|
@@ -116,11 +120,12 @@ export function checkSpendLimit(proposed, history, limits, nowMs) {
|
|
|
116
120
|
}
|
|
117
121
|
// Headroom is the tightest remaining amount limit, which is the number a
|
|
118
122
|
// caller needs to propose something that would pass.
|
|
123
|
+
const room = (cap, spent, what) => cap === undefined ? undefined : addExact(cap, -spent, what);
|
|
119
124
|
const caps = [
|
|
120
125
|
limits.perTransactionMinor,
|
|
121
|
-
limits.perHourMinor
|
|
122
|
-
limits.perDayMinor
|
|
123
|
-
limits.perWeekMinor
|
|
126
|
+
room(limits.perHourMinor, windows.hourMinor, "the hourly headroom"),
|
|
127
|
+
room(limits.perDayMinor, windows.dayMinor, "the daily headroom"),
|
|
128
|
+
room(limits.perWeekMinor, windows.weekMinor, "the weekly headroom"),
|
|
124
129
|
].filter((v) => v !== undefined);
|
|
125
130
|
return {
|
|
126
131
|
allowed: violations.length === 0,
|
|
@@ -141,8 +146,8 @@ export function refundAbuseSignals(refunds, orders, nowMs, thresholds = {}) {
|
|
|
141
146
|
const cutoff = nowMs - days * DAY;
|
|
142
147
|
const r = refunds.filter((x) => instant(x.at, `refund "${x.id}" at`) >= cutoff);
|
|
143
148
|
const o = orders.filter((x) => instant(x.at, `order "${x.id}" at`) >= cutoff);
|
|
144
|
-
const refundedMinor = r.
|
|
145
|
-
const spentMinor = o.
|
|
149
|
+
const refundedMinor = sumExact(r.map((x) => x.amountMinor), `the refunds in ${days} days`);
|
|
150
|
+
const spentMinor = sumExact(o.map((x) => x.amountMinor), `the orders in ${days} days`);
|
|
146
151
|
return {
|
|
147
152
|
days,
|
|
148
153
|
refunds: r.length,
|
|
@@ -151,7 +156,9 @@ export function refundAbuseSignals(refunds, orders, nowMs, thresholds = {}) {
|
|
|
151
156
|
spentMinor,
|
|
152
157
|
// Undefined rather than infinite when nothing was spent: a ratio
|
|
153
158
|
// against zero is not a large number, it is not a number.
|
|
154
|
-
ratioBps: spentMinor === 0
|
|
159
|
+
ratioBps: spentMinor === 0
|
|
160
|
+
? null
|
|
161
|
+
: toNumber(mathRound(BigInt(refundedMinor) * 10000n, BigInt(spentMinor)), `the ${days}-day refund ratio`),
|
|
155
162
|
};
|
|
156
163
|
});
|
|
157
164
|
const flags = [];
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact integer money in a language whose numbers are doubles.
|
|
3
|
+
*
|
|
4
|
+
* A double holds every integer up to 2^53 − 1 (9,007,199,254,740,991) and
|
|
5
|
+
* then starts skipping them. An amount past that arrives already rounded, and
|
|
6
|
+
* a sum or a product that crosses it rounds without saying so: [2^53 − 1, 2]
|
|
7
|
+
* totalled 9,007,199,254,740,992, and a tax of 19.99% on 1,595,908,809,793,069
|
|
8
|
+
* came out a unit high because the product `amount × bps` was past the line
|
|
9
|
+
* even though the amount and the answer were not.
|
|
10
|
+
*
|
|
11
|
+
* So the arithmetic is done in `bigint`, where it is exact, and a figure goes
|
|
12
|
+
* back into a JSON number only when it fits. One that does not is refused,
|
|
13
|
+
* by name — a tool whose whole job is the exact figure does not hand back an
|
|
14
|
+
* approximate one.
|
|
15
|
+
*/
|
|
16
|
+
/** Why a figure was refused rather than reported. */
|
|
17
|
+
export declare class InexactAmountError extends Error {
|
|
18
|
+
readonly name = "InexactAmountError";
|
|
19
|
+
}
|
|
20
|
+
/** An input amount, as a bigint. It must be a safe integer: past that it arrived rounded. */
|
|
21
|
+
export declare function big(value: number, what: string): bigint;
|
|
22
|
+
/** A result, back as a JSON number — or a refusal when it does not fit exactly. */
|
|
23
|
+
export declare function toNumber(value: bigint, what: string): number;
|
|
24
|
+
/** The exact sum of integer amounts. */
|
|
25
|
+
export declare function sumExact(values: Iterable<number>, what: string): number;
|
|
26
|
+
/** `a` combined with `b` exactly, for a sum or difference of two amounts. */
|
|
27
|
+
export declare function addExact(a: number, b: number, what: string): number;
|
|
28
|
+
/** Floor division (bigint division truncates toward zero; this rounds toward −∞). */
|
|
29
|
+
export declare function floorDiv(a: bigint, b: bigint): bigint;
|
|
30
|
+
/**
|
|
31
|
+
* `num / den` rounded the way `Math.round` rounds — to the nearest integer,
|
|
32
|
+
* a half toward +∞ — but exactly, however large the operands.
|
|
33
|
+
*/
|
|
34
|
+
export declare function mathRound(num: bigint, den: bigint): bigint;
|
|
35
|
+
/**
|
|
36
|
+
* A quantity as the decimal it is written as: `units / 10^scale`, exactly.
|
|
37
|
+
*
|
|
38
|
+
* Quantities (kilograms, hours, 0.37 of a coin) are decimals, and a double
|
|
39
|
+
* cannot hold most of them: 2.3 × 1001 is 2302.2999999999997 in binary. So a
|
|
40
|
+
* quantity is read as the SHORTEST text that reads back as the same double —
|
|
41
|
+
* which is the decimal the caller wrote (0.1, 1e-7, 10000000000) — and
|
|
42
|
+
* counted as a bigint on a decimal scale, where sums, differences and
|
|
43
|
+
* products with an integer price are exact however many digits they carry.
|
|
44
|
+
*/
|
|
45
|
+
export type Decimal = {
|
|
46
|
+
readonly units: bigint;
|
|
47
|
+
readonly scale: number;
|
|
48
|
+
};
|
|
49
|
+
export declare function decimalOf(value: number, what: string): Decimal;
|
|
50
|
+
/** `d`'s units on a finer (or equal) `scale`. */
|
|
51
|
+
export declare function atScale(d: Decimal, scale: number): bigint;
|
|
52
|
+
/** The exact sum (or, with `sign` −1, difference) of two decimals. */
|
|
53
|
+
export declare function addDecimal(a: Decimal, b: Decimal, sign?: 1n | -1n): Decimal;
|
|
54
|
+
/** Negative, zero or positive as `a` is below, equal to or above `b`. */
|
|
55
|
+
export declare function compareDecimal(a: Decimal, b: Decimal): number;
|
|
56
|
+
/**
|
|
57
|
+
* A decimal back as a JSON number — or a refusal when no double is exactly
|
|
58
|
+
* it. A quantity is reported as a number, and one that would come back as a
|
|
59
|
+
* different decimal (1234567890123457 − 0.1) is refused by name rather than
|
|
60
|
+
* reported off by what the double could not hold.
|
|
61
|
+
*/
|
|
62
|
+
export declare function decimalNumber(d: Decimal, what: string): number;
|
|
63
|
+
/**
|
|
64
|
+
* `num / den` rounded to whole minor units: to the nearest, a half away from
|
|
65
|
+
* zero, exactly. A fractional quantity times an integer price is a fraction
|
|
66
|
+
* of a minor unit; this is how it becomes a whole one.
|
|
67
|
+
*/
|
|
68
|
+
export declare function roundHalfAwayFromZero(num: bigint, den: bigint): bigint;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact integer money in a language whose numbers are doubles.
|
|
3
|
+
*
|
|
4
|
+
* A double holds every integer up to 2^53 − 1 (9,007,199,254,740,991) and
|
|
5
|
+
* then starts skipping them. An amount past that arrives already rounded, and
|
|
6
|
+
* a sum or a product that crosses it rounds without saying so: [2^53 − 1, 2]
|
|
7
|
+
* totalled 9,007,199,254,740,992, and a tax of 19.99% on 1,595,908,809,793,069
|
|
8
|
+
* came out a unit high because the product `amount × bps` was past the line
|
|
9
|
+
* even though the amount and the answer were not.
|
|
10
|
+
*
|
|
11
|
+
* So the arithmetic is done in `bigint`, where it is exact, and a figure goes
|
|
12
|
+
* back into a JSON number only when it fits. One that does not is refused,
|
|
13
|
+
* by name — a tool whose whole job is the exact figure does not hand back an
|
|
14
|
+
* approximate one.
|
|
15
|
+
*/
|
|
16
|
+
/** Why a figure was refused rather than reported. */
|
|
17
|
+
export class InexactAmountError extends Error {
|
|
18
|
+
name = "InexactAmountError";
|
|
19
|
+
}
|
|
20
|
+
const LIMIT_TEXT = "2^53 − 1 (9007199254740991)";
|
|
21
|
+
/** An input amount, as a bigint. It must be a safe integer: past that it arrived rounded. */
|
|
22
|
+
export function big(value, what) {
|
|
23
|
+
if (!Number.isSafeInteger(value)) {
|
|
24
|
+
throw new InexactAmountError(`${what} (${value}) is not an integer within ±${LIMIT_TEXT}, the largest amount this computes exactly — express it in a larger unit`);
|
|
25
|
+
}
|
|
26
|
+
return BigInt(value);
|
|
27
|
+
}
|
|
28
|
+
/** A result, back as a JSON number — or a refusal when it does not fit exactly. */
|
|
29
|
+
export function toNumber(value, what) {
|
|
30
|
+
if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER)) {
|
|
31
|
+
throw new InexactAmountError(`${what} comes to ${value}, past ±${LIMIT_TEXT}, so it cannot be reported exactly as a number — express the amounts in a larger unit`);
|
|
32
|
+
}
|
|
33
|
+
return Number(value);
|
|
34
|
+
}
|
|
35
|
+
/** The exact sum of integer amounts. */
|
|
36
|
+
export function sumExact(values, what) {
|
|
37
|
+
let total = 0n;
|
|
38
|
+
for (const value of values)
|
|
39
|
+
total += big(value, what);
|
|
40
|
+
return toNumber(total, what);
|
|
41
|
+
}
|
|
42
|
+
/** `a` combined with `b` exactly, for a sum or difference of two amounts. */
|
|
43
|
+
export function addExact(a, b, what) {
|
|
44
|
+
return toNumber(big(a, what) + big(b, what), what);
|
|
45
|
+
}
|
|
46
|
+
/** Floor division (bigint division truncates toward zero; this rounds toward −∞). */
|
|
47
|
+
export function floorDiv(a, b) {
|
|
48
|
+
const q = a / b;
|
|
49
|
+
return a % b !== 0n && a < 0n !== b < 0n ? q - 1n : q;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* `num / den` rounded the way `Math.round` rounds — to the nearest integer,
|
|
53
|
+
* a half toward +∞ — but exactly, however large the operands.
|
|
54
|
+
*/
|
|
55
|
+
export function mathRound(num, den) {
|
|
56
|
+
if (den === 0n)
|
|
57
|
+
throw new Error("denominator must not be zero");
|
|
58
|
+
const [n, d] = den < 0n ? [-num, -den] : [num, den];
|
|
59
|
+
return floorDiv(2n * n + d, 2n * d);
|
|
60
|
+
}
|
|
61
|
+
export function decimalOf(value, what) {
|
|
62
|
+
const match = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]\d+))?$/.exec(String(value));
|
|
63
|
+
if (match === null)
|
|
64
|
+
throw new InexactAmountError(`${what} (${value}) is not a finite decimal`);
|
|
65
|
+
const fraction = match[3] ?? "";
|
|
66
|
+
let digits = `${match[2]}${fraction}`;
|
|
67
|
+
let scale = fraction.length - Number(match[4] ?? 0);
|
|
68
|
+
if (scale < 0) {
|
|
69
|
+
digits += "0".repeat(-scale);
|
|
70
|
+
scale = 0;
|
|
71
|
+
}
|
|
72
|
+
const units = BigInt(digits);
|
|
73
|
+
return { units: match[1] === "-" ? -units : units, scale };
|
|
74
|
+
}
|
|
75
|
+
/** `d`'s units on a finer (or equal) `scale`. */
|
|
76
|
+
export function atScale(d, scale) {
|
|
77
|
+
return d.units * 10n ** BigInt(scale - d.scale);
|
|
78
|
+
}
|
|
79
|
+
/** The exact sum (or, with `sign` −1, difference) of two decimals. */
|
|
80
|
+
export function addDecimal(a, b, sign = 1n) {
|
|
81
|
+
const scale = Math.max(a.scale, b.scale);
|
|
82
|
+
return { units: atScale(a, scale) + sign * atScale(b, scale), scale };
|
|
83
|
+
}
|
|
84
|
+
/** Negative, zero or positive as `a` is below, equal to or above `b`. */
|
|
85
|
+
export function compareDecimal(a, b) {
|
|
86
|
+
const scale = Math.max(a.scale, b.scale);
|
|
87
|
+
const left = atScale(a, scale);
|
|
88
|
+
const right = atScale(b, scale);
|
|
89
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* A decimal back as a JSON number — or a refusal when no double is exactly
|
|
93
|
+
* it. A quantity is reported as a number, and one that would come back as a
|
|
94
|
+
* different decimal (1234567890123457 − 0.1) is refused by name rather than
|
|
95
|
+
* reported off by what the double could not hold.
|
|
96
|
+
*/
|
|
97
|
+
export function decimalNumber(d, what) {
|
|
98
|
+
const negative = d.units < 0n;
|
|
99
|
+
const magnitude = (negative ? -d.units : d.units).toString();
|
|
100
|
+
const text = d.scale === 0
|
|
101
|
+
? magnitude
|
|
102
|
+
: `${magnitude.padStart(d.scale + 1, "0").slice(0, -d.scale)}.${magnitude
|
|
103
|
+
.padStart(d.scale + 1, "0")
|
|
104
|
+
.slice(-d.scale)}`;
|
|
105
|
+
const value = Number(`${negative ? "-" : ""}${text}`);
|
|
106
|
+
if (!Number.isFinite(value) || compareDecimal(decimalOf(value, what), d) !== 0) {
|
|
107
|
+
throw new InexactAmountError(`${what} comes to ${negative ? "-" : ""}${text}, which a JSON number cannot hold exactly — express the quantities in a coarser unit`);
|
|
108
|
+
}
|
|
109
|
+
return value === 0 ? 0 : value;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* `num / den` rounded to whole minor units: to the nearest, a half away from
|
|
113
|
+
* zero, exactly. A fractional quantity times an integer price is a fraction
|
|
114
|
+
* of a minor unit; this is how it becomes a whole one.
|
|
115
|
+
*/
|
|
116
|
+
export function roundHalfAwayFromZero(num, den) {
|
|
117
|
+
if (den === 0n)
|
|
118
|
+
throw new Error("denominator must not be zero");
|
|
119
|
+
const [n, d] = den < 0n ? [-num, -den] : [num, den];
|
|
120
|
+
const magnitude = (2n * (n < 0n ? -n : n) + d) / (2n * d);
|
|
121
|
+
return n < 0n ? -magnitude : magnitude;
|
|
122
|
+
}
|
package/dist/lib/lots.d.ts
CHANGED
|
@@ -11,6 +11,13 @@
|
|
|
11
11
|
* as an integer remainder and the final consumption takes whatever is left,
|
|
12
12
|
* so a fully consumed lot always accounts for exactly its cost with no
|
|
13
13
|
* rounding drift.
|
|
14
|
+
*
|
|
15
|
+
* Quantities are counted as exact decimals, not as doubles. Each is read as
|
|
16
|
+
* the decimal it is written as (0.1 is one tenth, not the binary fraction
|
|
17
|
+
* nearest it), and every lot and disposal is scaled to the same number of
|
|
18
|
+
* decimal places as a bigint. So ten disposals of 0.1 leave nothing of a lot
|
|
19
|
+
* of 1, and five units left of a lot of ten billion stay five units — where a
|
|
20
|
+
* tolerance for binary noise would have to guess which remainders are real.
|
|
14
21
|
*/
|
|
15
22
|
export declare const LOT_METHODS: readonly ["fifo", "lifo", "hifo", "specific"];
|
|
16
23
|
export type LotMethod = (typeof LOT_METHODS)[number];
|
package/dist/lib/lots.js
CHANGED
|
@@ -11,9 +11,59 @@
|
|
|
11
11
|
* as an integer remainder and the final consumption takes whatever is left,
|
|
12
12
|
* so a fully consumed lot always accounts for exactly its cost with no
|
|
13
13
|
* rounding drift.
|
|
14
|
+
*
|
|
15
|
+
* Quantities are counted as exact decimals, not as doubles. Each is read as
|
|
16
|
+
* the decimal it is written as (0.1 is one tenth, not the binary fraction
|
|
17
|
+
* nearest it), and every lot and disposal is scaled to the same number of
|
|
18
|
+
* decimal places as a bigint. So ten disposals of 0.1 leave nothing of a lot
|
|
19
|
+
* of 1, and five units left of a lot of ten billion stay five units — where a
|
|
20
|
+
* tolerance for binary noise would have to guess which remainders are real.
|
|
14
21
|
*/
|
|
22
|
+
import { addExact, big, decimalOf, mathRound, sumExact, toNumber } from "./exact";
|
|
15
23
|
export const LOT_METHODS = ["fifo", "lifo", "hifo", "specific"];
|
|
16
24
|
const YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
|
25
|
+
/*
|
|
26
|
+
* A disposal may come up short of the lots by up to 1e-9 units and still be
|
|
27
|
+
* read as consuming them: a caller's own float sum (0.1 + 0.2 is
|
|
28
|
+
* 0.30000000000000004) is not a claim to hold more than was bought.
|
|
29
|
+
*/
|
|
30
|
+
/** `units / 10^scale` back as a number: the double nearest the exact decimal. */
|
|
31
|
+
function quantityNumber(units, scale) {
|
|
32
|
+
if (scale === 0)
|
|
33
|
+
return Number(units);
|
|
34
|
+
const negative = units < 0n;
|
|
35
|
+
const text = (negative ? -units : units).toString().padStart(scale + 1, "0");
|
|
36
|
+
const value = Number(`${text.slice(0, -scale)}.${text.slice(-scale)}`);
|
|
37
|
+
return negative ? -value : value;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Move `heap[at]` down past every child taken before it. `before(a, b)` is
|
|
41
|
+
* negative when `a` is taken first; no two lots tie.
|
|
42
|
+
*/
|
|
43
|
+
function siftDown(heap, at, before) {
|
|
44
|
+
const item = heap[at];
|
|
45
|
+
let i = at;
|
|
46
|
+
for (;;) {
|
|
47
|
+
const left = 2 * i + 1;
|
|
48
|
+
if (left >= heap.length)
|
|
49
|
+
break;
|
|
50
|
+
const right = left + 1;
|
|
51
|
+
const first = right < heap.length && before(heap[right], heap[left]) < 0 ? right : left;
|
|
52
|
+
if (before(heap[first], item) >= 0)
|
|
53
|
+
break;
|
|
54
|
+
heap[i] = heap[first];
|
|
55
|
+
i = first;
|
|
56
|
+
}
|
|
57
|
+
heap[i] = item;
|
|
58
|
+
}
|
|
59
|
+
/** Drop the top of the heap. */
|
|
60
|
+
function popTop(heap, before) {
|
|
61
|
+
const last = heap.pop();
|
|
62
|
+
if (heap.length === 0)
|
|
63
|
+
return;
|
|
64
|
+
heap[0] = last;
|
|
65
|
+
siftDown(heap, 0, before);
|
|
66
|
+
}
|
|
17
67
|
function instant(value, what) {
|
|
18
68
|
if (!/(?:Z|[+-]\d{2}:?\d{2})$/i.test(value.trim())) {
|
|
19
69
|
throw new Error(`${what} ("${value}") has no UTC offset — an offset-less timestamp means local time and would put a holding period on the wrong side of a year boundary depending on the machine`);
|
|
@@ -33,87 +83,133 @@ export function computeCostBasis(lots, disposals, method) {
|
|
|
33
83
|
throw new Error(`lot "${lot.id}" has quantity ${lot.quantity}`);
|
|
34
84
|
if (!Number.isInteger(lot.costMinor))
|
|
35
85
|
throw new Error(`lot "${lot.id}" cost must be minor units`);
|
|
86
|
+
big(lot.costMinor, `lot "${lot.id}" costMinor`);
|
|
36
87
|
instant(lot.acquiredAt, `lot "${lot.id}" acquiredAt`);
|
|
37
88
|
}
|
|
89
|
+
for (const disposal of disposals) {
|
|
90
|
+
if (disposal.quantity <= 0) {
|
|
91
|
+
throw new Error(`disposal "${disposal.id}" has quantity ${disposal.quantity}`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Every quantity on one decimal scale, so each subtraction is exact.
|
|
95
|
+
const quantities = [
|
|
96
|
+
...lots.map((l) => decimalOf(l.quantity, `lot "${l.id}" quantity`)),
|
|
97
|
+
...disposals.map((d) => decimalOf(d.quantity, `disposal "${d.id}" quantity`)),
|
|
98
|
+
];
|
|
99
|
+
const scale = quantities.reduce((most, q) => Math.max(most, q.scale), 0);
|
|
100
|
+
const scaled = (value, what) => {
|
|
101
|
+
const q = decimalOf(value, what);
|
|
102
|
+
return q.units * 10n ** BigInt(scale - q.scale);
|
|
103
|
+
};
|
|
104
|
+
// SHORTFALL_TOLERANCE on this scale; nothing when the scale is coarser.
|
|
105
|
+
const shortfallAllowed = scale >= 9 ? 10n ** BigInt(scale - 9) : 0n;
|
|
38
106
|
// Working copies: remaining cost is an integer so a lot cannot drift.
|
|
39
107
|
const open = lots.map((lot) => ({
|
|
40
108
|
...lot,
|
|
41
|
-
|
|
109
|
+
remainingUnits: scaled(lot.quantity, `lot "${lot.id}" quantity`),
|
|
42
110
|
remainingCostMinor: lot.costMinor,
|
|
43
111
|
acquiredMs: instant(lot.acquiredAt, "acquiredAt"),
|
|
44
112
|
}));
|
|
45
113
|
const results = [];
|
|
114
|
+
const byId = (a, b) => (a.id < b.id ? -1 : 1);
|
|
115
|
+
const takenBefore = method === "fifo"
|
|
116
|
+
? (a, b) => a.acquiredMs - b.acquiredMs || byId(a, b)
|
|
117
|
+
: method === "lifo"
|
|
118
|
+
? (a, b) => b.acquiredMs - a.acquiredMs || byId(a, b)
|
|
119
|
+
: method === "hifo"
|
|
120
|
+
? (a, b) => {
|
|
121
|
+
// Highest cost per unit first, which realizes the smallest
|
|
122
|
+
// gain — compared by cross-multiplying, so two lots a float
|
|
123
|
+
// would call equal are still told apart.
|
|
124
|
+
const left = BigInt(b.remainingCostMinor) * a.remainingUnits;
|
|
125
|
+
const right = BigInt(a.remainingCostMinor) * b.remainingUnits;
|
|
126
|
+
return left > right ? 1 : left < right ? -1 : byId(a, b);
|
|
127
|
+
}
|
|
128
|
+
: undefined;
|
|
129
|
+
// The open lots as a binary heap in that order, the next lot to take at
|
|
130
|
+
// the top. A disposal takes lots off the top; the only lot whose place can
|
|
131
|
+
// change is the one it took part of (its cost per unit moves by the
|
|
132
|
+
// rounding of that part), and that one is sifted to where it now belongs.
|
|
133
|
+
// Re-sorting every open lot for every disposal made HIFO quadratic in the
|
|
134
|
+
// lots at the schema's limit, and so did moving the part-taken lot within
|
|
135
|
+
// a sorted list: rounding can send it from the front to the back. A list
|
|
136
|
+
// sorted once is already a heap.
|
|
137
|
+
const heap = takenBefore === undefined ? [] : [...open].sort(takenBefore);
|
|
138
|
+
// Every lot by its id, built once, for the specific method. A lot is open
|
|
139
|
+
// while it has units left; that is read at lookup, where an index of the
|
|
140
|
+
// open lots rebuilt for every disposal cost the lots times the disposals.
|
|
141
|
+
const lotById = new Map(method === "specific" ? open.map((lot) => [lot.id, lot]) : []);
|
|
46
142
|
for (const disposal of disposals) {
|
|
47
143
|
const disposedMs = instant(disposal.disposedAt, `disposal "${disposal.id}" disposedAt`);
|
|
48
|
-
|
|
49
|
-
throw new Error(`disposal "${disposal.id}" has quantity ${disposal.quantity}`);
|
|
50
|
-
}
|
|
51
|
-
let order = open.filter((l) => l.remainingQuantity > 0);
|
|
144
|
+
let named = [];
|
|
52
145
|
if (method === "specific") {
|
|
53
146
|
const wanted = disposal.lotIds;
|
|
54
147
|
if (!wanted || wanted.length === 0) {
|
|
55
148
|
throw new Error(`disposal "${disposal.id}" uses the specific-identification method but names no lots`);
|
|
56
149
|
}
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (!lot) {
|
|
150
|
+
named = wanted.map((id) => {
|
|
151
|
+
const lot = lotById.get(id);
|
|
152
|
+
if (lot === undefined || lot.remainingUnits <= 0n) {
|
|
61
153
|
throw new Error(`disposal "${disposal.id}" names lot "${id}", which is not open`);
|
|
62
154
|
}
|
|
63
155
|
return lot;
|
|
64
156
|
});
|
|
65
157
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
else if (method === "lifo") {
|
|
70
|
-
order = [...order].sort((a, b) => b.acquiredMs - a.acquiredMs || (a.id < b.id ? -1 : 1));
|
|
71
|
-
}
|
|
72
|
-
else {
|
|
73
|
-
// Highest cost per unit first, which realizes the smallest gain.
|
|
74
|
-
order = [...order].sort((a, b) => b.remainingCostMinor / b.remainingQuantity - a.remainingCostMinor / a.remainingQuantity ||
|
|
75
|
-
(a.id < b.id ? -1 : 1));
|
|
76
|
-
}
|
|
77
|
-
let toConsume = disposal.quantity;
|
|
158
|
+
const disposalUnits = scaled(disposal.quantity, `disposal "${disposal.id}" quantity`);
|
|
159
|
+
let toConsume = disposalUnits;
|
|
78
160
|
const consumed = [];
|
|
79
161
|
let costMinor = 0;
|
|
80
|
-
|
|
81
|
-
|
|
162
|
+
// The named lots in turn, or the top of the heap until the disposal is
|
|
163
|
+
// met: a lot taken whole leaves the heap, and the one taken in part is
|
|
164
|
+
// sifted down from the top to its new place.
|
|
165
|
+
for (let at = 0; toConsume > 0n; at++) {
|
|
166
|
+
const lot = takenBefore === undefined ? named[at] : heap[0];
|
|
167
|
+
if (lot === undefined)
|
|
82
168
|
break;
|
|
83
|
-
const take = Math.min(toConsume, lot.remainingQuantity);
|
|
84
169
|
// Taking the whole remainder consumes the whole remaining cost. That
|
|
85
170
|
// is what keeps a lot's costs summing to exactly what it cost, however
|
|
86
171
|
// many partial disposals came before.
|
|
87
|
-
const
|
|
172
|
+
const whole = toConsume >= lot.remainingUnits;
|
|
173
|
+
const take = whole ? lot.remainingUnits : toConsume;
|
|
174
|
+
const takeCost = whole
|
|
88
175
|
? lot.remainingCostMinor
|
|
89
|
-
:
|
|
90
|
-
lot.
|
|
91
|
-
lot.remainingCostMinor
|
|
176
|
+
: toNumber(mathRound(BigInt(lot.remainingCostMinor) * take, lot.remainingUnits), `disposal "${disposal.id}" cost`);
|
|
177
|
+
lot.remainingUnits -= take;
|
|
178
|
+
lot.remainingCostMinor = addExact(lot.remainingCostMinor, -takeCost, `lot "${lot.id}" remaining cost`);
|
|
92
179
|
toConsume -= take;
|
|
93
|
-
costMinor
|
|
180
|
+
costMinor = addExact(costMinor, takeCost, `disposal "${disposal.id}" cost`);
|
|
94
181
|
consumed.push({
|
|
95
182
|
lotId: lot.id,
|
|
96
|
-
quantity: take,
|
|
183
|
+
quantity: quantityNumber(take, scale),
|
|
184
|
+
units: take,
|
|
97
185
|
costMinor: takeCost,
|
|
98
186
|
acquiredAt: lot.acquiredAt,
|
|
99
187
|
longTerm: disposedMs - lot.acquiredMs > YEAR_MS,
|
|
100
188
|
});
|
|
189
|
+
if (takenBefore !== undefined) {
|
|
190
|
+
if (lot.remainingUnits === 0n)
|
|
191
|
+
popTop(heap, takenBefore);
|
|
192
|
+
else
|
|
193
|
+
siftDown(heap, 0, takenBefore);
|
|
194
|
+
}
|
|
101
195
|
}
|
|
102
|
-
if (toConsume >
|
|
103
|
-
throw new Error(`disposal "${disposal.id}" needs ${disposal.quantity} but only ${
|
|
196
|
+
if (toConsume > shortfallAllowed) {
|
|
197
|
+
throw new Error(`disposal "${disposal.id}" needs ${disposal.quantity} but only ${quantityNumber(disposalUnits - toConsume, scale)} was open — a disposal cannot exceed the lots held`);
|
|
104
198
|
}
|
|
105
199
|
// Proceeds follow the units, so the split between short and long term is
|
|
106
200
|
// allocated by quantity rather than by cost.
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
201
|
+
const what = `disposal "${disposal.id}"`;
|
|
202
|
+
const proceeds = big(disposal.proceedsMinor, `${what} proceedsMinor`);
|
|
203
|
+
const gainMinor = toNumber(proceeds - big(costMinor, what), `${what} gain`);
|
|
204
|
+
let longTermProceeds = 0n;
|
|
205
|
+
let longTermCost = 0n;
|
|
110
206
|
for (const entry of consumed) {
|
|
111
207
|
if (!entry.longTerm)
|
|
112
208
|
continue;
|
|
113
|
-
longTermProceeds +=
|
|
114
|
-
longTermCost += entry.costMinor;
|
|
209
|
+
longTermProceeds += mathRound(proceeds * entry.units, disposalUnits);
|
|
210
|
+
longTermCost += big(entry.costMinor, what);
|
|
115
211
|
}
|
|
116
|
-
const longTermGainMinor = longTermProceeds - longTermCost;
|
|
212
|
+
const longTermGainMinor = toNumber(longTermProceeds - longTermCost, `${what} long-term gain`);
|
|
117
213
|
results.push({
|
|
118
214
|
id: disposal.id,
|
|
119
215
|
quantity: disposal.quantity,
|
|
@@ -121,22 +217,22 @@ export function computeCostBasis(lots, disposals, method) {
|
|
|
121
217
|
costMinor,
|
|
122
218
|
gainMinor,
|
|
123
219
|
longTermGainMinor,
|
|
124
|
-
shortTermGainMinor: gainMinor -
|
|
125
|
-
consumed,
|
|
220
|
+
shortTermGainMinor: addExact(gainMinor, -longTermGainMinor, `${what} short-term gain`),
|
|
221
|
+
consumed: consumed.map(({ units: _units, ...entry }) => entry),
|
|
126
222
|
});
|
|
127
223
|
}
|
|
128
|
-
const remaining = open.filter((l) => l.
|
|
224
|
+
const remaining = open.filter((l) => l.remainingUnits > 0n);
|
|
129
225
|
return {
|
|
130
226
|
method,
|
|
131
227
|
disposals: results,
|
|
132
|
-
realizedGainMinor: results.
|
|
228
|
+
realizedGainMinor: sumExact(results.map((r) => r.gainMinor), "the realized gain"),
|
|
133
229
|
remainingLots: remaining.map((l) => ({
|
|
134
230
|
id: l.id,
|
|
135
231
|
acquiredAt: l.acquiredAt,
|
|
136
|
-
quantity: l.
|
|
232
|
+
quantity: quantityNumber(l.remainingUnits, scale),
|
|
137
233
|
costMinor: l.remainingCostMinor,
|
|
138
234
|
})),
|
|
139
|
-
remainingQuantity: remaining.reduce((
|
|
140
|
-
remainingCostMinor: remaining.
|
|
235
|
+
remainingQuantity: quantityNumber(remaining.reduce((sum, l) => sum + l.remainingUnits, 0n), scale),
|
|
236
|
+
remainingCostMinor: sumExact(remaining.map((l) => l.remainingCostMinor), "the remaining cost"),
|
|
141
237
|
};
|
|
142
238
|
}
|
package/dist/lib/match.d.ts
CHANGED
|
@@ -38,7 +38,11 @@ export type MatchPair = {
|
|
|
38
38
|
readonly poQuantity: number;
|
|
39
39
|
readonly receivedQuantity: number | null;
|
|
40
40
|
readonly quantityDelta: number;
|
|
41
|
-
/**
|
|
41
|
+
/**
|
|
42
|
+
* What the variance costs, positive when the invoice asks for more. With a
|
|
43
|
+
* fractional quantity (kilograms, hours) the figure is rounded to whole
|
|
44
|
+
* minor units, half away from zero.
|
|
45
|
+
*/
|
|
42
46
|
readonly exposureMinor: number;
|
|
43
47
|
readonly reasons: ReadonlyArray<string>;
|
|
44
48
|
};
|