@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.
@@ -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
- /** Parse a money string to minor units, exactly, without floating point. */
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 value = Number.parseInt(scaled === "" ? "0" : scaled, 10);
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 rejected = [];
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 blocks = text.match(/<STMTTRN>[\s\S]*?<\/STMTTRN>/gi) ?? [];
177
- const field = (block, tag) => {
178
- const m = new RegExp(`<${tag}>([^<\\r\\n]*)`, "i").exec(block);
179
- return (m?.[1] ?? "").trim();
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 = parseMoneyMinor(field(block, "TRNAMT"), decimals, false);
184
- if (date === null || amount === null) {
185
- rejected.push({ row: i + 1, reason: "the transaction has no readable date or amount" });
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
- transactions.push({
312
+ tally.add({
190
313
  id: field(block, "FITID") || `ofx-${i + 1}`,
191
314
  date,
192
315
  description: name,
193
- amountMinor: amount,
194
- direction: amount < 0 ? "debit" : "credit",
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
- else {
201
- const rows = parseCsvRows(text);
202
- if (rows.length < 2) {
203
- throw new Error("the CSV has no data rows under its header");
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
- const headers = rows[0].map((h) => h.trim());
206
- const at = {
207
- date: findColumn(headers, "date", options.columns?.date),
208
- description: findColumn(headers, "description", options.columns?.description),
209
- amount: findColumn(headers, "amount", options.columns?.amount),
210
- debit: findColumn(headers, "debit", options.columns?.debit),
211
- credit: findColumn(headers, "credit", options.columns?.credit),
212
- balance: findColumn(headers, "balance", options.columns?.balance),
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
- if (at.amount === -1 && at.debit === -1 && at.credit === -1) {
219
- throw new Error(`no amount, debit or credit column found; the header is: ${headers.join(", ")}`);
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
- const body = rows.slice(1);
222
- const rawDates = body.map((r) => r[at.date] ?? "");
223
- let order = options.dateOrder;
224
- if (order === undefined) {
225
- if (!datesAreUnambiguous(rawDates)) {
226
- 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");
227
- }
228
- order = rawDates.some((d) => /^\d{1,2}[/.-]/.test(d.trim())) ? "dmy" : "iso";
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
- for (const [i, row] of body.entries()) {
231
- const date = parseDate(row[at.date] ?? "", order);
232
- if (date === null) {
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
- return {
268
- format,
269
- transactions,
270
- count: transactions.length,
271
- totalMinor: transactions.reduce((s, t) => s + t.amountMinor, 0),
272
- debitMinor: transactions
273
- .filter((t) => t.direction === "debit")
274
- .reduce((s, t) => s + t.amountMinor, 0),
275
- creditMinor: transactions
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
- /** Round a rational to an integer, by the caller's rule. */
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
- /** Round a rational to an integer, by the caller's rule. */
17
- export function roundMinor(numerator, denominator, mode) {
18
- if (denominator === 0)
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 < 0 !== denominator < 0;
21
- const n = Math.abs(numerator);
22
- const d = Math.abs(denominator);
23
- const whole = Math.floor(n / d);
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 > 0 ? whole + 1 : whole;
36
+ result = remainder > 0n ? whole + 1n : whole;
32
37
  break;
33
38
  case "half-even": {
34
- const twice = remainder * 2;
39
+ const twice = remainder * 2n;
35
40
  if (twice > d)
36
- result = whole + 1;
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 % 2 === 0 ? whole : whole + 1;
47
+ result = whole % 2n === 0n ? whole : whole + 1n;
43
48
  break;
44
49
  }
45
50
  default:
46
- result = remainder * 2 >= d ? whole + 1 : whole;
51
+ result = remainder * 2n >= d ? whole + 1n : whole;
47
52
  }
48
53
  return negative ? -result : result;
49
54
  }
50
- const BPS_DENOMINATOR = 10_000;
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
- if (!Number.isInteger(lines.reduce((s, l) => s + l.amountMinor, 0))) {
63
- throw new Error("amounts must be integer minor units");
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 = 0;
77
+ let exactTaxNumerator = 0n;
78
+ let netTotal = 0n;
79
+ let summedTax = 0n;
70
80
  for (const line of lines) {
71
- if (!Number.isInteger(line.amountMinor)) {
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 netMinor;
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
- ? Math.round((multiplier * (BPS_DENOMINATOR + rate.bps)) / BPS_DENOMINATOR)
84
- : multiplier + rate.bps;
92
+ ? mathRound(multiplier * (BPS_DENOMINATOR + bps), BPS_DENOMINATOR)
93
+ : multiplier + bps;
85
94
  }
86
- netMinor = roundMinor(line.amountMinor * BPS_DENOMINATOR, multiplier, mode);
95
+ net = roundMinorExact(amount * BPS_DENOMINATOR, multiplier, mode);
87
96
  }
88
97
  else {
89
- netMinor = line.amountMinor;
98
+ net = amount;
90
99
  }
91
100
  const breakdown = [];
92
- let lineTax = 0;
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 ? netMinor + lineTax : netMinor;
99
- const numerator = base * rate.bps;
100
- const taxMinor = roundMinor(numerator, BPS_DENOMINATOR, mode);
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({ code: rate.code, bps: rate.bps, taxMinor });
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: 0, netMinor: 0 };
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 + 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: netMinor + lineTax,
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 netMinor = results.reduce((s, r) => s + r.netMinor, 0);
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: netMinor + taxMinor,
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]) => ({ 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
- /** Raised when a caller-supplied path resolves outside the workspace root. */
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
- constructor(toolName: string, attemptedPath: string);
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.