@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/statement.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* Only what the file says is reported. No categorization, no counterparty
|
|
17
17
|
* enrichment, no guessing at what a memo line means.
|
|
18
18
|
*/
|
|
19
|
+
import { InexactAmountError } from "./exact";
|
|
19
20
|
export const DATE_ORDERS = ["iso", "dmy", "mdy"];
|
|
20
21
|
export const STATEMENT_FORMATS = ["csv", "ofx"];
|
|
21
22
|
/** RFC 4180 fields: quotes, doubled quotes inside them, embedded newlines. */
|
|
@@ -60,7 +61,16 @@ export function parseCsvRows(text) {
|
|
|
60
61
|
}
|
|
61
62
|
return rows.filter((r) => r.some((f) => f.trim() !== ""));
|
|
62
63
|
}
|
|
63
|
-
/**
|
|
64
|
+
/** The most digits a safe integer has: 2^53 − 1 is 9,007,199,254,740,991. */
|
|
65
|
+
const SAFE_DIGITS = 16;
|
|
66
|
+
/**
|
|
67
|
+
* Parse a money string to minor units, exactly, without floating point.
|
|
68
|
+
*
|
|
69
|
+
* Returns null for text that is not an amount. An amount past 2^53 − 1 minor
|
|
70
|
+
* units is refused with an {@link InexactAmountError}: a JSON number past
|
|
71
|
+
* that has already been rounded, and 90,071,992,547,409.93 read as
|
|
72
|
+
* 9007199254740992 cents is a figure one cent out that nothing flags.
|
|
73
|
+
*/
|
|
64
74
|
export function parseMoneyMinor(raw, decimals, decimalComma) {
|
|
65
75
|
let text = raw.trim();
|
|
66
76
|
if (text === "")
|
|
@@ -96,7 +106,15 @@ export function parseMoneyMinor(raw, decimals, decimalComma) {
|
|
|
96
106
|
return null;
|
|
97
107
|
// Pad or truncate to the currency's exponent rather than rounding a float.
|
|
98
108
|
const scaled = `${whole}${fraction.padEnd(decimals, "0").slice(0, decimals)}`;
|
|
99
|
-
const
|
|
109
|
+
const digits = scaled.replace(/^0+/, "");
|
|
110
|
+
// Counted before it is converted: past sixteen digits it cannot fit, and a
|
|
111
|
+
// megabyte of digits is not worth a bigint to find that out.
|
|
112
|
+
if (digits.length > SAFE_DIGITS ||
|
|
113
|
+
(digits.length === SAFE_DIGITS && BigInt(digits) > BigInt(Number.MAX_SAFE_INTEGER))) {
|
|
114
|
+
const shown = raw.trim().length > 40 ? `${raw.trim().slice(0, 40)}…` : raw.trim();
|
|
115
|
+
throw new InexactAmountError(`"${shown}" is more than 2^53 − 1 (9007199254740991) minor units, the largest amount this reads exactly, so it is left out rather than rounded`);
|
|
116
|
+
}
|
|
117
|
+
const value = digits === "" ? 0 : Number(digits);
|
|
100
118
|
return negative ? -value : value;
|
|
101
119
|
}
|
|
102
120
|
/**
|
|
@@ -164,133 +182,214 @@ function findColumn(headers, role, override) {
|
|
|
164
182
|
const aliases = COLUMN_ALIASES[role] ?? [];
|
|
165
183
|
return headers.findIndex((h) => aliases.includes(h.trim().toLowerCase()));
|
|
166
184
|
}
|
|
185
|
+
/**
|
|
186
|
+
* The `<STMTTRN>` blocks of an OFX file, in one forward pass over its tags.
|
|
187
|
+
*
|
|
188
|
+
* OFX 1.x is SGML, where an end tag may be left out, so a block that is
|
|
189
|
+
* never closed ends where the next one starts, at the transaction list's
|
|
190
|
+
* own tags, or at the end of the file — and is read like any other rather
|
|
191
|
+
* than dropped. (A lazy `<STMTTRN>…</STMTTRN>` match instead rescanned to
|
|
192
|
+
* the end of the file from every unclosed tag, which was quadratic, and
|
|
193
|
+
* swallowed the next transaction into an unclosed one.) A stray end tag is
|
|
194
|
+
* ignored.
|
|
195
|
+
*/
|
|
196
|
+
export function* ofxTransactionBlocks(text) {
|
|
197
|
+
const tag = /<(\/?)(STMTTRN|BANKTRANLIST)>/gi;
|
|
198
|
+
let open = -1;
|
|
199
|
+
for (let m = tag.exec(text); m !== null; m = tag.exec(text)) {
|
|
200
|
+
const closing = m[1] === "/";
|
|
201
|
+
const isTransaction = m[2].toUpperCase() === "STMTTRN";
|
|
202
|
+
// Its own end tag closes a block; any other boundary ends it unclosed.
|
|
203
|
+
if (open !== -1)
|
|
204
|
+
yield text.slice(open, m.index);
|
|
205
|
+
open = -1;
|
|
206
|
+
if (!closing && isTransaction)
|
|
207
|
+
open = m.index;
|
|
208
|
+
}
|
|
209
|
+
if (open !== -1)
|
|
210
|
+
yield text.slice(open);
|
|
211
|
+
}
|
|
212
|
+
/** One OFX element's value, e.g. `<TRNAMT>-12.50`. Built once per tag, not once per block. */
|
|
213
|
+
const OFX_FIELDS = Object.fromEntries(["DTPOSTED", "TRNAMT", "NAME", "MEMO", "FITID", "CHECKNUM", "REFNUM"].map((tag) => [
|
|
214
|
+
tag,
|
|
215
|
+
new RegExp(`<${tag}>([^<\\r\\n]*)`, "i"),
|
|
216
|
+
]));
|
|
217
|
+
/**
|
|
218
|
+
* Where a parse keeps what it has read: the lists up to what the caller will
|
|
219
|
+
* show, the counts and the exact totals for every row.
|
|
220
|
+
*/
|
|
221
|
+
class StatementTally {
|
|
222
|
+
keepTransactions;
|
|
223
|
+
keepRejected;
|
|
224
|
+
transactions = [];
|
|
225
|
+
rejected = [];
|
|
226
|
+
count = 0;
|
|
227
|
+
rejectedCount = 0;
|
|
228
|
+
total = 0n;
|
|
229
|
+
debit = 0n;
|
|
230
|
+
credit = 0n;
|
|
231
|
+
constructor(keepTransactions, keepRejected) {
|
|
232
|
+
this.keepTransactions = keepTransactions;
|
|
233
|
+
this.keepRejected = keepRejected;
|
|
234
|
+
}
|
|
235
|
+
add(transaction) {
|
|
236
|
+
this.count++;
|
|
237
|
+
const amount = BigInt(transaction.amountMinor);
|
|
238
|
+
this.total += amount;
|
|
239
|
+
if (transaction.direction === "debit")
|
|
240
|
+
this.debit += amount;
|
|
241
|
+
else
|
|
242
|
+
this.credit += amount;
|
|
243
|
+
if (this.transactions.length < this.keepTransactions)
|
|
244
|
+
this.transactions.push(transaction);
|
|
245
|
+
}
|
|
246
|
+
reject(row, reason) {
|
|
247
|
+
this.rejectedCount++;
|
|
248
|
+
if (this.rejected.length < this.keepRejected)
|
|
249
|
+
this.rejected.push({ row, reason });
|
|
250
|
+
}
|
|
251
|
+
result(format, dateOrder) {
|
|
252
|
+
const limit = BigInt(Number.MAX_SAFE_INTEGER);
|
|
253
|
+
const sums = [
|
|
254
|
+
["total", this.total],
|
|
255
|
+
["debit total", this.debit],
|
|
256
|
+
["credit total", this.credit],
|
|
257
|
+
];
|
|
258
|
+
// Each total is reported when it fits, and left out, by name, when not.
|
|
259
|
+
const over = sums.filter(([, v]) => v > limit || v < -limit);
|
|
260
|
+
const fit = (v) => (v > limit || v < -limit ? null : Number(v));
|
|
261
|
+
return {
|
|
262
|
+
format,
|
|
263
|
+
transactions: this.transactions,
|
|
264
|
+
count: this.count,
|
|
265
|
+
totalMinor: fit(this.total),
|
|
266
|
+
debitMinor: fit(this.debit),
|
|
267
|
+
creditMinor: fit(this.credit),
|
|
268
|
+
totalsUnavailable: over.length === 0
|
|
269
|
+
? null
|
|
270
|
+
: `the statement's ${over.map(([name, v]) => `${name} comes to ${v}`).join(" and its ")} minor units, past ±2^53 − 1 (9007199254740991), so ${over.length === 1 ? "it is" : "they are"} left out rather than rounded; every transaction's own amount is exact`,
|
|
271
|
+
dateOrder,
|
|
272
|
+
rejected: this.rejected,
|
|
273
|
+
rejectedCount: this.rejectedCount,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/** A money column read for one row: the amount, or why the row is refused. */
|
|
278
|
+
function readMoney(raw, column, decimals, decimalComma) {
|
|
279
|
+
try {
|
|
280
|
+
return { value: parseMoneyMinor(raw, decimals, decimalComma) };
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
if (err instanceof InexactAmountError)
|
|
284
|
+
return { refused: `the ${column} ${err.message}` };
|
|
285
|
+
throw err;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
167
288
|
export function parseStatement(text, options = {}) {
|
|
168
289
|
const format = options.format ?? (/<OFX>|<STMTTRN>/i.test(text) ? "ofx" : "csv");
|
|
169
290
|
const decimals = options.decimals ?? 2;
|
|
170
291
|
const decimalComma = options.decimalComma === true;
|
|
171
|
-
const
|
|
172
|
-
const transactions = [];
|
|
292
|
+
const tally = new StatementTally(options.keep?.transactions ?? Number.POSITIVE_INFINITY, options.keep?.rejected ?? Number.POSITIVE_INFINITY);
|
|
173
293
|
if (format === "ofx") {
|
|
174
294
|
// OFX is SGML with optional closing tags; the transaction blocks are all
|
|
175
295
|
// that matters here and they are regular enough to read directly.
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
};
|
|
181
|
-
for (const [i, block] of blocks.entries()) {
|
|
296
|
+
const field = (block, tag) => (OFX_FIELDS[tag].exec(block)?.[1] ?? "").trim();
|
|
297
|
+
let i = -1;
|
|
298
|
+
for (const block of ofxTransactionBlocks(text)) {
|
|
299
|
+
i++;
|
|
182
300
|
const date = parseDate(field(block, "DTPOSTED"), "iso");
|
|
183
|
-
const amount =
|
|
184
|
-
if (
|
|
185
|
-
|
|
301
|
+
const amount = readMoney(field(block, "TRNAMT"), "amount", decimals, false);
|
|
302
|
+
if ("refused" in amount) {
|
|
303
|
+
tally.reject(i + 1, amount.refused);
|
|
186
304
|
continue;
|
|
187
305
|
}
|
|
306
|
+
if (date === null || amount.value === null) {
|
|
307
|
+
tally.reject(i + 1, "the transaction has no readable date or amount");
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const amountMinor = amount.value;
|
|
188
311
|
const name = field(block, "NAME") || field(block, "MEMO");
|
|
189
|
-
|
|
312
|
+
tally.add({
|
|
190
313
|
id: field(block, "FITID") || `ofx-${i + 1}`,
|
|
191
314
|
date,
|
|
192
315
|
description: name,
|
|
193
|
-
amountMinor
|
|
194
|
-
direction:
|
|
316
|
+
amountMinor,
|
|
317
|
+
direction: amountMinor < 0 ? "debit" : "credit",
|
|
195
318
|
reference: field(block, "CHECKNUM") || field(block, "REFNUM"),
|
|
196
319
|
balanceMinor: null,
|
|
197
320
|
});
|
|
198
321
|
}
|
|
322
|
+
return tally.result(format, "iso");
|
|
323
|
+
}
|
|
324
|
+
const rows = parseCsvRows(text);
|
|
325
|
+
if (rows.length < 2) {
|
|
326
|
+
throw new Error("the CSV has no data rows under its header");
|
|
327
|
+
}
|
|
328
|
+
const headers = rows[0].map((h) => h.trim());
|
|
329
|
+
const at = {
|
|
330
|
+
date: findColumn(headers, "date", options.columns?.date),
|
|
331
|
+
description: findColumn(headers, "description", options.columns?.description),
|
|
332
|
+
amount: findColumn(headers, "amount", options.columns?.amount),
|
|
333
|
+
debit: findColumn(headers, "debit", options.columns?.debit),
|
|
334
|
+
credit: findColumn(headers, "credit", options.columns?.credit),
|
|
335
|
+
balance: findColumn(headers, "balance", options.columns?.balance),
|
|
336
|
+
reference: findColumn(headers, "reference", options.columns?.reference),
|
|
337
|
+
};
|
|
338
|
+
if (at.date === -1) {
|
|
339
|
+
throw new Error(`no date column found; the header is: ${headers.join(", ")}`);
|
|
199
340
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
341
|
+
if (at.amount === -1 && at.debit === -1 && at.credit === -1) {
|
|
342
|
+
throw new Error(`no amount, debit or credit column found; the header is: ${headers.join(", ")}`);
|
|
343
|
+
}
|
|
344
|
+
const body = rows.slice(1);
|
|
345
|
+
const rawDates = body.map((r) => r[at.date] ?? "");
|
|
346
|
+
let order = options.dateOrder;
|
|
347
|
+
if (order === undefined) {
|
|
348
|
+
if (!datesAreUnambiguous(rawDates)) {
|
|
349
|
+
throw new Error("the dates in this file could be day-first or month-first and the file does not say which; pass dateOrder explicitly rather than have the months guessed at");
|
|
204
350
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
reference: findColumn(headers, "reference", options.columns?.reference),
|
|
214
|
-
};
|
|
215
|
-
if (at.date === -1) {
|
|
216
|
-
throw new Error(`no date column found; the header is: ${headers.join(", ")}`);
|
|
351
|
+
order = rawDates.some((d) => /^\d{1,2}[/.-]/.test(d.trim())) ? "dmy" : "iso";
|
|
352
|
+
}
|
|
353
|
+
const money = (row, column, name) => column === -1 ? { value: null } : readMoney(row[column] ?? "", name, decimals, decimalComma);
|
|
354
|
+
for (const [i, row] of body.entries()) {
|
|
355
|
+
const date = parseDate(row[at.date] ?? "", order);
|
|
356
|
+
if (date === null) {
|
|
357
|
+
tally.reject(i + 2, `"${row[at.date] ?? ""}" is not a date this can read`);
|
|
358
|
+
continue;
|
|
217
359
|
}
|
|
218
|
-
|
|
219
|
-
|
|
360
|
+
const amount = money(row, at.amount, "amount");
|
|
361
|
+
const debit = money(row, at.debit, "debit");
|
|
362
|
+
const credit = money(row, at.credit, "credit");
|
|
363
|
+
const balance = money(row, at.balance, "balance");
|
|
364
|
+
const refusal = [amount, debit, credit, balance].find((m) => "refused" in m);
|
|
365
|
+
if (refusal !== undefined && "refused" in refusal) {
|
|
366
|
+
tally.reject(i + 2, refusal.refused);
|
|
367
|
+
continue;
|
|
220
368
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
369
|
+
let amountMinor = "value" in amount ? amount.value : null;
|
|
370
|
+
if (amountMinor === null) {
|
|
371
|
+
const out = "value" in debit ? debit.value : null;
|
|
372
|
+
const into = "value" in credit ? credit.value : null;
|
|
373
|
+
// A debit column holds a positive number meaning money out, so the
|
|
374
|
+
// sign has to be applied rather than read.
|
|
375
|
+
if (out !== null && out !== 0)
|
|
376
|
+
amountMinor = -Math.abs(out);
|
|
377
|
+
else if (into !== null && into !== 0)
|
|
378
|
+
amountMinor = Math.abs(into);
|
|
229
379
|
}
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
rejected.push({
|
|
234
|
-
row: i + 2,
|
|
235
|
-
reason: `"${row[at.date] ?? ""}" is not a date this can read`,
|
|
236
|
-
});
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
let amountMinor = null;
|
|
240
|
-
if (at.amount !== -1) {
|
|
241
|
-
amountMinor = parseMoneyMinor(row[at.amount] ?? "", decimals, decimalComma);
|
|
242
|
-
}
|
|
243
|
-
if (amountMinor === null && (at.debit !== -1 || at.credit !== -1)) {
|
|
244
|
-
const debit = at.debit === -1 ? null : parseMoneyMinor(row[at.debit] ?? "", decimals, decimalComma);
|
|
245
|
-
const credit = at.credit === -1 ? null : parseMoneyMinor(row[at.credit] ?? "", decimals, decimalComma);
|
|
246
|
-
// A debit column holds a positive number meaning money out, so the
|
|
247
|
-
// sign has to be applied rather than read.
|
|
248
|
-
if (debit !== null && debit !== 0)
|
|
249
|
-
amountMinor = -Math.abs(debit);
|
|
250
|
-
else if (credit !== null && credit !== 0)
|
|
251
|
-
amountMinor = Math.abs(credit);
|
|
252
|
-
}
|
|
253
|
-
if (amountMinor === null) {
|
|
254
|
-
rejected.push({ row: i + 2, reason: "no readable amount in this row" });
|
|
255
|
-
continue;
|
|
256
|
-
}
|
|
257
|
-
transactions.push({
|
|
258
|
-
id: (at.reference !== -1 ? row[at.reference] : "") || `row-${i + 2}`,
|
|
259
|
-
date,
|
|
260
|
-
description: (at.description === -1 ? "" : (row[at.description] ?? "")).trim(),
|
|
261
|
-
amountMinor,
|
|
262
|
-
direction: amountMinor < 0 ? "debit" : "credit",
|
|
263
|
-
reference: at.reference === -1 ? "" : (row[at.reference] ?? "").trim(),
|
|
264
|
-
balanceMinor: at.balance === -1 ? null : parseMoneyMinor(row[at.balance] ?? "", decimals, decimalComma),
|
|
265
|
-
});
|
|
380
|
+
if (amountMinor === null) {
|
|
381
|
+
tally.reject(i + 2, "no readable amount in this row");
|
|
382
|
+
continue;
|
|
266
383
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
.filter((t) => t.direction === "credit")
|
|
277
|
-
.reduce((s, t) => s + t.amountMinor, 0),
|
|
278
|
-
dateOrder: order,
|
|
279
|
-
rejected,
|
|
280
|
-
};
|
|
384
|
+
tally.add({
|
|
385
|
+
id: (at.reference !== -1 ? row[at.reference] : "") || `row-${i + 2}`,
|
|
386
|
+
date,
|
|
387
|
+
description: (at.description === -1 ? "" : (row[at.description] ?? "")).trim(),
|
|
388
|
+
amountMinor,
|
|
389
|
+
direction: amountMinor < 0 ? "debit" : "credit",
|
|
390
|
+
reference: at.reference === -1 ? "" : (row[at.reference] ?? "").trim(),
|
|
391
|
+
balanceMinor: "value" in balance ? balance.value : null,
|
|
392
|
+
});
|
|
281
393
|
}
|
|
282
|
-
return
|
|
283
|
-
format,
|
|
284
|
-
transactions,
|
|
285
|
-
count: transactions.length,
|
|
286
|
-
totalMinor: transactions.reduce((s, t) => s + t.amountMinor, 0),
|
|
287
|
-
debitMinor: transactions
|
|
288
|
-
.filter((t) => t.direction === "debit")
|
|
289
|
-
.reduce((s, t) => s + t.amountMinor, 0),
|
|
290
|
-
creditMinor: transactions
|
|
291
|
-
.filter((t) => t.direction === "credit")
|
|
292
|
-
.reduce((s, t) => s + t.amountMinor, 0),
|
|
293
|
-
dateOrder: "iso",
|
|
294
|
-
rejected,
|
|
295
|
-
};
|
|
394
|
+
return tally.result(format, order);
|
|
296
395
|
}
|
package/dist/lib/tax.d.ts
CHANGED
|
@@ -74,6 +74,12 @@ export type TaxResult = {
|
|
|
74
74
|
readonly rounding: RoundingMode;
|
|
75
75
|
readonly scope: RoundingScope;
|
|
76
76
|
};
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Round a rational to an integer, by the caller's rule, exactly: the
|
|
79
|
+
* numerator is an amount times a rate in basis points, which passes 2^53 long
|
|
80
|
+
* before the amount or the answer does.
|
|
81
|
+
*/
|
|
82
|
+
export declare function roundMinorExact(numerator: bigint, denominator: bigint, mode: RoundingMode): bigint;
|
|
83
|
+
/** {@link roundMinorExact} over integer numbers, for callers holding numbers. */
|
|
78
84
|
export declare function roundMinor(numerator: number, denominator: number, mode: RoundingMode): number;
|
|
79
85
|
export declare function calculateTax(lines: ReadonlyArray<TaxLine>, rates: ReadonlyArray<TaxRate>, options?: TaxOptions): TaxResult;
|
package/dist/lib/tax.js
CHANGED
|
@@ -11,16 +11,21 @@
|
|
|
11
11
|
* happens per line or once per invoice. Jurisdictions differ, the totals
|
|
12
12
|
* differ, and a tool that picked one silently would be wrong half the time.
|
|
13
13
|
*/
|
|
14
|
+
import { big, mathRound, toNumber } from "./exact";
|
|
14
15
|
export const ROUNDING_MODES = ["half-up", "half-even", "down", "up"];
|
|
15
16
|
export const ROUNDING_SCOPES = ["line", "invoice"];
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
/**
|
|
18
|
+
* Round a rational to an integer, by the caller's rule, exactly: the
|
|
19
|
+
* numerator is an amount times a rate in basis points, which passes 2^53 long
|
|
20
|
+
* before the amount or the answer does.
|
|
21
|
+
*/
|
|
22
|
+
export function roundMinorExact(numerator, denominator, mode) {
|
|
23
|
+
if (denominator === 0n)
|
|
19
24
|
throw new Error("denominator must not be zero");
|
|
20
|
-
const negative = numerator <
|
|
21
|
-
const n =
|
|
22
|
-
const d =
|
|
23
|
-
const whole =
|
|
25
|
+
const negative = numerator < 0n !== denominator < 0n;
|
|
26
|
+
const n = numerator < 0n ? -numerator : numerator;
|
|
27
|
+
const d = denominator < 0n ? -denominator : denominator;
|
|
28
|
+
const whole = n / d;
|
|
24
29
|
const remainder = n - whole * d;
|
|
25
30
|
let result;
|
|
26
31
|
switch (mode) {
|
|
@@ -28,26 +33,30 @@ export function roundMinor(numerator, denominator, mode) {
|
|
|
28
33
|
result = whole;
|
|
29
34
|
break;
|
|
30
35
|
case "up":
|
|
31
|
-
result = remainder >
|
|
36
|
+
result = remainder > 0n ? whole + 1n : whole;
|
|
32
37
|
break;
|
|
33
38
|
case "half-even": {
|
|
34
|
-
const twice = remainder *
|
|
39
|
+
const twice = remainder * 2n;
|
|
35
40
|
if (twice > d)
|
|
36
|
-
result = whole +
|
|
41
|
+
result = whole + 1n;
|
|
37
42
|
else if (twice < d)
|
|
38
43
|
result = whole;
|
|
39
44
|
// Exactly half: go to the even neighbour. Over many lines this is what
|
|
40
45
|
// keeps a total from drifting upward the way half-up does.
|
|
41
46
|
else
|
|
42
|
-
result = whole %
|
|
47
|
+
result = whole % 2n === 0n ? whole : whole + 1n;
|
|
43
48
|
break;
|
|
44
49
|
}
|
|
45
50
|
default:
|
|
46
|
-
result = remainder *
|
|
51
|
+
result = remainder * 2n >= d ? whole + 1n : whole;
|
|
47
52
|
}
|
|
48
53
|
return negative ? -result : result;
|
|
49
54
|
}
|
|
50
|
-
|
|
55
|
+
/** {@link roundMinorExact} over integer numbers, for callers holding numbers. */
|
|
56
|
+
export function roundMinor(numerator, denominator, mode) {
|
|
57
|
+
return toNumber(roundMinorExact(big(numerator, "numerator"), big(denominator, "denominator"), mode), "the rounded amount");
|
|
58
|
+
}
|
|
59
|
+
const BPS_DENOMINATOR = 10000n;
|
|
51
60
|
export function calculateTax(lines, rates, options = {}) {
|
|
52
61
|
const mode = options.rounding ?? "half-up";
|
|
53
62
|
const scope = options.scope ?? "line";
|
|
@@ -59,60 +68,66 @@ export function calculateTax(lines, rates, options = {}) {
|
|
|
59
68
|
}
|
|
60
69
|
}
|
|
61
70
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
71
|
+
// Exact throughout: every amount and rate as a bigint, and a figure goes
|
|
72
|
+
// back to a number only when it fits. See ./exact.
|
|
65
73
|
const tally = new Map();
|
|
66
74
|
const results = [];
|
|
67
75
|
// Exact totals kept as numerator over BPS_DENOMINATOR, so invoice-scope
|
|
68
76
|
// rounding rounds the true sum rather than a sum of rounded pieces.
|
|
69
|
-
let exactTaxNumerator =
|
|
77
|
+
let exactTaxNumerator = 0n;
|
|
78
|
+
let netTotal = 0n;
|
|
79
|
+
let summedTax = 0n;
|
|
70
80
|
for (const line of lines) {
|
|
71
|
-
|
|
72
|
-
throw new Error(`line "${line.id}" has a non-integer amount; use minor units`);
|
|
73
|
-
}
|
|
81
|
+
const amount = big(line.amountMinor, `line "${line.id}" amountMinor`);
|
|
74
82
|
const applied = line.exempt || options.reverseCharge ? [] : line.taxCodes;
|
|
75
83
|
const rateList = applied.map((code) => byCodeIndex.get(code));
|
|
76
84
|
// Net is the base everything is charged on. When listed prices already
|
|
77
85
|
// include tax it has to be recovered first: gross / (1 + total rate).
|
|
78
|
-
let
|
|
86
|
+
let net;
|
|
79
87
|
if (options.pricesIncludeTax && rateList.length > 0) {
|
|
80
88
|
let multiplier = BPS_DENOMINATOR;
|
|
81
89
|
for (const rate of rateList) {
|
|
90
|
+
const bps = big(rate.bps, `rate "${rate.code}" bps`);
|
|
82
91
|
multiplier = rate.compound
|
|
83
|
-
?
|
|
84
|
-
: multiplier +
|
|
92
|
+
? mathRound(multiplier * (BPS_DENOMINATOR + bps), BPS_DENOMINATOR)
|
|
93
|
+
: multiplier + bps;
|
|
85
94
|
}
|
|
86
|
-
|
|
95
|
+
net = roundMinorExact(amount * BPS_DENOMINATOR, multiplier, mode);
|
|
87
96
|
}
|
|
88
97
|
else {
|
|
89
|
-
|
|
98
|
+
net = amount;
|
|
90
99
|
}
|
|
91
100
|
const breakdown = [];
|
|
92
|
-
let lineTax =
|
|
101
|
+
let lineTax = 0n;
|
|
93
102
|
for (const rate of rateList) {
|
|
94
103
|
// `compound` describes the base THIS rate is charged on — net plus the
|
|
95
104
|
// taxes already applied — not an effect on the rates after it. Adding
|
|
96
105
|
// to the base afterwards instead left the compound rate itself charged
|
|
97
106
|
// on the bare net, which is the whole thing the flag exists to change.
|
|
98
|
-
const base = rate.compound ?
|
|
99
|
-
const numerator = base * rate.bps;
|
|
100
|
-
const taxMinor =
|
|
107
|
+
const base = rate.compound ? net + lineTax : net;
|
|
108
|
+
const numerator = base * big(rate.bps, `rate "${rate.code}" bps`);
|
|
109
|
+
const taxMinor = roundMinorExact(numerator, BPS_DENOMINATOR, mode);
|
|
101
110
|
exactTaxNumerator += numerator;
|
|
102
|
-
breakdown.push({
|
|
111
|
+
breakdown.push({
|
|
112
|
+
code: rate.code,
|
|
113
|
+
bps: rate.bps,
|
|
114
|
+
taxMinor: toNumber(taxMinor, `line "${line.id}" ${rate.code} tax`),
|
|
115
|
+
});
|
|
103
116
|
lineTax += taxMinor;
|
|
104
|
-
const seen = tally.get(rate.code) ?? { bps: rate.bps, taxMinor:
|
|
117
|
+
const seen = tally.get(rate.code) ?? { bps: rate.bps, taxMinor: 0n, netMinor: 0n };
|
|
105
118
|
tally.set(rate.code, {
|
|
106
119
|
bps: rate.bps,
|
|
107
120
|
taxMinor: seen.taxMinor + taxMinor,
|
|
108
|
-
netMinor: seen.netMinor +
|
|
121
|
+
netMinor: seen.netMinor + net,
|
|
109
122
|
});
|
|
110
123
|
}
|
|
124
|
+
netTotal += net;
|
|
125
|
+
summedTax += lineTax;
|
|
111
126
|
results.push({
|
|
112
127
|
id: line.id,
|
|
113
|
-
netMinor,
|
|
114
|
-
taxMinor: lineTax,
|
|
115
|
-
grossMinor:
|
|
128
|
+
netMinor: toNumber(net, `line "${line.id}" net`),
|
|
129
|
+
taxMinor: toNumber(lineTax, `line "${line.id}" tax`),
|
|
130
|
+
grossMinor: toNumber(net + lineTax, `line "${line.id}" gross`),
|
|
116
131
|
breakdown,
|
|
117
132
|
note: options.reverseCharge
|
|
118
133
|
? "reverse charge: the customer accounts for the tax"
|
|
@@ -121,16 +136,19 @@ export function calculateTax(lines, rates, options = {}) {
|
|
|
121
136
|
: "",
|
|
122
137
|
});
|
|
123
138
|
}
|
|
124
|
-
const
|
|
125
|
-
const summedTax = results.reduce((s, r) => s + r.taxMinor, 0);
|
|
126
|
-
const taxMinor = scope === "invoice" ? roundMinor(exactTaxNumerator, BPS_DENOMINATOR, mode) : summedTax;
|
|
139
|
+
const taxTotal = scope === "invoice" ? roundMinorExact(exactTaxNumerator, BPS_DENOMINATOR, mode) : summedTax;
|
|
127
140
|
return {
|
|
128
141
|
lines: results,
|
|
129
|
-
netMinor,
|
|
130
|
-
taxMinor,
|
|
131
|
-
grossMinor:
|
|
142
|
+
netMinor: toNumber(netTotal, "the invoice's net"),
|
|
143
|
+
taxMinor: toNumber(taxTotal, "the invoice's tax"),
|
|
144
|
+
grossMinor: toNumber(netTotal + taxTotal, "the invoice's gross"),
|
|
132
145
|
byCode: [...tally.entries()]
|
|
133
|
-
.map(([code, v]) => ({
|
|
146
|
+
.map(([code, v]) => ({
|
|
147
|
+
code,
|
|
148
|
+
bps: v.bps,
|
|
149
|
+
taxMinor: toNumber(v.taxMinor, `the ${code} tax`),
|
|
150
|
+
netMinor: toNumber(v.netMinor, `the net taxed at ${code}`),
|
|
151
|
+
}))
|
|
134
152
|
.sort((a, b) => (a.code < b.code ? -1 : 1)),
|
|
135
153
|
reverseCharge: options.reverseCharge === true,
|
|
136
154
|
rounding: mode,
|
package/dist/paths.d.ts
CHANGED
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { CrewhausError } from "@crewhaus/errors";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The longest path this resolves, in UTF-16 code units; a longer one is
|
|
4
|
+
* refused before it is walked. Nothing that long can be opened or created:
|
|
5
|
+
* PATH_MAX is 1,024 bytes on macOS and 4,096 on Linux, Windows stops at
|
|
6
|
+
* 32,767 UTF-16 units, and a path's UTF-8 form is never shorter than its
|
|
7
|
+
* UTF-16 one. The limit is twice that because the part of a path that exists
|
|
8
|
+
* is itself under PATH_MAX, so the part that does not is over PATH_MAX on its
|
|
9
|
+
* own: no spelling of the place reaches it, not even one through a symlink
|
|
10
|
+
* that shortens the part that exists.
|
|
11
|
+
*/
|
|
12
|
+
export declare const MAX_PATH_CHARS: number;
|
|
13
|
+
/**
|
|
14
|
+
* Raised when a caller-supplied path resolves outside the workspace root, or
|
|
15
|
+
* is longer than any path can be.
|
|
16
|
+
*/
|
|
3
17
|
export declare class ToolPermissionError extends CrewhausError {
|
|
4
18
|
readonly name = "ToolPermissionError";
|
|
5
19
|
readonly toolName: string;
|
|
6
20
|
readonly path: string;
|
|
7
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Why, when it is not that the location escapes the workspace root: a path
|
|
23
|
+
* longer than any path can be. A caller that rewords the refusal says this.
|
|
24
|
+
*/
|
|
25
|
+
readonly reason: string | undefined;
|
|
26
|
+
constructor(toolName: string, attemptedPath: string, reason?: string);
|
|
8
27
|
}
|
|
9
28
|
/**
|
|
10
29
|
* A validated path, in both forms the tools need.
|