@crewhaus/tool-money 0.7.0

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.
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Three-way match: invoice against purchase order against goods receipt.
3
+ *
4
+ * This is the standard accounts-payable control, and it is entirely
5
+ * mechanical — pair the lines, compare price and quantity, and report what
6
+ * falls outside tolerance. A model reading two documents to decide whether
7
+ * 4,800 matches 4,750 is being paid to do subtraction, and it will sometimes
8
+ * get it wrong on an invoice nobody re-reads.
9
+ */
10
+ export const MATCH_STATUSES = [
11
+ "matched",
12
+ "price-variance",
13
+ "quantity-variance",
14
+ "both-variance",
15
+ "over-receipt",
16
+ "not-received",
17
+ ];
18
+ /** Lowercased, punctuation folded, runs of space collapsed. */
19
+ function normalizeDescription(text) {
20
+ return text
21
+ .toLowerCase()
22
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
23
+ .replace(/\s+/g, " ")
24
+ .trim();
25
+ }
26
+ function withinTolerance(actual, expected, percentBps, absolute) {
27
+ const delta = Math.abs(actual - expected);
28
+ if (delta === 0)
29
+ return true;
30
+ // Either bound may pass. A percentage alone is useless on a cheap line and
31
+ // an absolute alone is useless on an expensive one.
32
+ const byPercent = percentBps !== undefined && Math.abs(expected) * percentBps >= delta * 10_000;
33
+ const byAbsolute = absolute !== undefined && delta <= absolute;
34
+ return byPercent || byAbsolute;
35
+ }
36
+ export function matchInvoiceToPurchaseOrder(invoiceLines, poLines, receiptLines = [], tolerance = {}) {
37
+ for (const [label, list] of [
38
+ ["invoice", invoiceLines],
39
+ ["purchase order", poLines],
40
+ ]) {
41
+ const seen = new Set();
42
+ for (const line of list) {
43
+ if (seen.has(line.id))
44
+ throw new Error(`two ${label} lines share the id "${line.id}"`);
45
+ seen.add(line.id);
46
+ }
47
+ }
48
+ const received = new Map();
49
+ for (const entry of receiptLines) {
50
+ received.set(entry.poLineId, (received.get(entry.poLineId) ?? 0) + entry.quantity);
51
+ }
52
+ const remainingPo = new Map(poLines.map((l) => [l.id, l]));
53
+ const pairs = [];
54
+ const unmatchedInvoiceLines = [];
55
+ for (const invoice of invoiceLines) {
56
+ // Reference first, then SKU, then description. Each is weaker than the
57
+ // one before, and the pair records which was used so a reviewer can see
58
+ // that a match rested on fuzzy text rather than on an identifier.
59
+ let po = invoice.poLineId === undefined ? undefined : remainingPo.get(invoice.poLineId);
60
+ let matchedBy = "reference";
61
+ if (!po && invoice.sku !== undefined) {
62
+ po = [...remainingPo.values()].find((l) => l.sku !== undefined && l.sku === invoice.sku);
63
+ if (po)
64
+ matchedBy = "sku";
65
+ }
66
+ if (!po && invoice.description !== undefined) {
67
+ const wanted = normalizeDescription(invoice.description);
68
+ po = [...remainingPo.values()].find((l) => l.description !== undefined && normalizeDescription(l.description) === wanted);
69
+ if (po)
70
+ matchedBy = "description";
71
+ }
72
+ if (!po) {
73
+ unmatchedInvoiceLines.push(invoice.id);
74
+ continue;
75
+ }
76
+ remainingPo.delete(po.id);
77
+ const reasons = [];
78
+ const priceOk = withinTolerance(invoice.unitPriceMinor, po.unitPriceMinor, tolerance.pricePercentBps, tolerance.priceAbsoluteMinor);
79
+ const quantityOk = withinTolerance(invoice.quantity, po.quantity, tolerance.quantityPercentBps, tolerance.quantityAbsolute);
80
+ if (!priceOk) {
81
+ reasons.push(`unit price ${invoice.unitPriceMinor} against ${po.unitPriceMinor} on the order`);
82
+ }
83
+ if (!quantityOk) {
84
+ reasons.push(`quantity ${invoice.quantity} against ${po.quantity} on the order`);
85
+ }
86
+ const receivedQuantity = received.has(po.id) ? received.get(po.id) : null;
87
+ let status = !priceOk && !quantityOk
88
+ ? "both-variance"
89
+ : !priceOk
90
+ ? "price-variance"
91
+ : !quantityOk
92
+ ? "quantity-variance"
93
+ : "matched";
94
+ // The receipt is the third leg: billing for more than arrived is the
95
+ // failure this control exists to catch, and it is invisible in a two-way
96
+ // match however well price and quantity agree with the order.
97
+ if (receiptLines.length > 0) {
98
+ if (receivedQuantity === null || receivedQuantity === 0) {
99
+ status = "not-received";
100
+ reasons.push("nothing has been received against this order line");
101
+ }
102
+ else if (invoice.quantity > receivedQuantity) {
103
+ status = "over-receipt";
104
+ reasons.push(`invoiced ${invoice.quantity} but only ${receivedQuantity} received`);
105
+ }
106
+ }
107
+ const billable = Math.min(invoice.quantity, receivedQuantity ?? invoice.quantity);
108
+ const exposureMinor = invoice.quantity * invoice.unitPriceMinor -
109
+ billable * Math.min(invoice.unitPriceMinor, po.unitPriceMinor);
110
+ pairs.push({
111
+ invoiceLineId: invoice.id,
112
+ poLineId: po.id,
113
+ matchedBy,
114
+ status,
115
+ invoiceUnitPriceMinor: invoice.unitPriceMinor,
116
+ poUnitPriceMinor: po.unitPriceMinor,
117
+ priceDeltaMinor: invoice.unitPriceMinor - po.unitPriceMinor,
118
+ invoiceQuantity: invoice.quantity,
119
+ poQuantity: po.quantity,
120
+ receivedQuantity,
121
+ quantityDelta: invoice.quantity - po.quantity,
122
+ exposureMinor: status === "matched" ? 0 : exposureMinor,
123
+ reasons,
124
+ });
125
+ }
126
+ const exceptions = pairs.filter((p) => p.status !== "matched").length;
127
+ return {
128
+ pairs,
129
+ unmatchedInvoiceLines,
130
+ unmatchedPoLines: [...remainingPo.keys()],
131
+ ok: exceptions === 0 && unmatchedInvoiceLines.length === 0,
132
+ exceptions,
133
+ totalExposureMinor: pairs.reduce((s, p) => s + p.exposureMinor, 0),
134
+ threeWay: receiptLines.length > 0,
135
+ };
136
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Normalize a bank, card or exchange export into one transaction list.
3
+ *
4
+ * Two things in this job corrupt ledgers quietly, and both are guessed at by
5
+ * every convenient importer:
6
+ *
7
+ * - **Date order.** `03/04/2026` is 3 April in most of the world and 4 March
8
+ * in the United States. Nothing in the file says which, and a wrong guess
9
+ * moves transactions between months — so a file whose dates are ambiguous
10
+ * is REFUSED unless the caller states the order, rather than parsed into
11
+ * something that looks fine.
12
+ * - **Sign.** Some exports use a signed amount, some a debit column and a
13
+ * credit column, and some a positive amount with a separate direction. Get
14
+ * it backwards and a reconciliation balances to exactly twice the error.
15
+ *
16
+ * Only what the file says is reported. No categorization, no counterparty
17
+ * enrichment, no guessing at what a memo line means.
18
+ */
19
+ export declare const DATE_ORDERS: readonly ["iso", "dmy", "mdy"];
20
+ export type DateOrder = (typeof DATE_ORDERS)[number];
21
+ export declare const STATEMENT_FORMATS: readonly ["csv", "ofx"];
22
+ export type StatementFormat = (typeof STATEMENT_FORMATS)[number];
23
+ export type Transaction = {
24
+ /** The file's own id where it has one, otherwise a stable positional id. */
25
+ readonly id: string;
26
+ /** ISO-8601 date, always. */
27
+ readonly date: string;
28
+ readonly description: string;
29
+ /** Minor units, negative for money leaving the account. */
30
+ readonly amountMinor: number;
31
+ readonly direction: "debit" | "credit";
32
+ readonly reference: string;
33
+ readonly balanceMinor: number | null;
34
+ };
35
+ export type ParseOptions = {
36
+ readonly format?: StatementFormat;
37
+ readonly dateOrder?: DateOrder;
38
+ /** Decimal comma and dot-as-thousands, as much of Europe writes it. */
39
+ readonly decimalComma?: boolean;
40
+ /** Column names, when the header does not use recognizable ones. */
41
+ readonly columns?: {
42
+ readonly date?: string;
43
+ readonly description?: string;
44
+ readonly amount?: string;
45
+ readonly debit?: string;
46
+ readonly credit?: string;
47
+ readonly balance?: string;
48
+ readonly reference?: string;
49
+ };
50
+ /** Currency's minor-unit exponent. 2 for most, 0 for JPY. */
51
+ readonly decimals?: number;
52
+ };
53
+ export type ParseResult = {
54
+ readonly format: StatementFormat;
55
+ readonly transactions: ReadonlyArray<Transaction>;
56
+ readonly count: number;
57
+ readonly totalMinor: number;
58
+ readonly debitMinor: number;
59
+ readonly creditMinor: number;
60
+ readonly dateOrder: DateOrder;
61
+ /** Rows the file contained that could not be read, with the reason. */
62
+ readonly rejected: ReadonlyArray<{
63
+ readonly row: number;
64
+ readonly reason: string;
65
+ }>;
66
+ };
67
+ /** RFC 4180 fields: quotes, doubled quotes inside them, embedded newlines. */
68
+ export declare function parseCsvRows(text: string): string[][];
69
+ /** Parse a money string to minor units, exactly, without floating point. */
70
+ export declare function parseMoneyMinor(raw: string, decimals: number, decimalComma: boolean): number | null;
71
+ /**
72
+ * Parse a date, refusing what cannot be read unambiguously.
73
+ *
74
+ * Returns null rather than a guess. The caller turns that into a rejected
75
+ * row with a reason, which is recoverable; a silently wrong month is not.
76
+ */
77
+ export declare function parseDate(raw: string, order: DateOrder): string | null;
78
+ /** True when every slashed date in the file reads the same either way. */
79
+ export declare function datesAreUnambiguous(values: ReadonlyArray<string>): boolean;
80
+ export declare function parseStatement(text: string, options?: ParseOptions): ParseResult;
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Normalize a bank, card or exchange export into one transaction list.
3
+ *
4
+ * Two things in this job corrupt ledgers quietly, and both are guessed at by
5
+ * every convenient importer:
6
+ *
7
+ * - **Date order.** `03/04/2026` is 3 April in most of the world and 4 March
8
+ * in the United States. Nothing in the file says which, and a wrong guess
9
+ * moves transactions between months — so a file whose dates are ambiguous
10
+ * is REFUSED unless the caller states the order, rather than parsed into
11
+ * something that looks fine.
12
+ * - **Sign.** Some exports use a signed amount, some a debit column and a
13
+ * credit column, and some a positive amount with a separate direction. Get
14
+ * it backwards and a reconciliation balances to exactly twice the error.
15
+ *
16
+ * Only what the file says is reported. No categorization, no counterparty
17
+ * enrichment, no guessing at what a memo line means.
18
+ */
19
+ export const DATE_ORDERS = ["iso", "dmy", "mdy"];
20
+ export const STATEMENT_FORMATS = ["csv", "ofx"];
21
+ /** RFC 4180 fields: quotes, doubled quotes inside them, embedded newlines. */
22
+ export function parseCsvRows(text) {
23
+ const rows = [];
24
+ let row = [];
25
+ let field = "";
26
+ let quoted = false;
27
+ for (let i = 0; i < text.length; i++) {
28
+ const ch = text[i];
29
+ if (quoted) {
30
+ if (ch === '"') {
31
+ if (text[i + 1] === '"') {
32
+ field += '"';
33
+ i++;
34
+ }
35
+ else
36
+ quoted = false;
37
+ }
38
+ else
39
+ field += ch;
40
+ continue;
41
+ }
42
+ if (ch === '"')
43
+ quoted = true;
44
+ else if (ch === ",") {
45
+ row.push(field);
46
+ field = "";
47
+ }
48
+ else if (ch === "\n") {
49
+ row.push(field);
50
+ rows.push(row);
51
+ row = [];
52
+ field = "";
53
+ }
54
+ else if (ch !== "\r")
55
+ field += ch;
56
+ }
57
+ if (field !== "" || row.length > 0) {
58
+ row.push(field);
59
+ rows.push(row);
60
+ }
61
+ return rows.filter((r) => r.some((f) => f.trim() !== ""));
62
+ }
63
+ /** Parse a money string to minor units, exactly, without floating point. */
64
+ export function parseMoneyMinor(raw, decimals, decimalComma) {
65
+ let text = raw.trim();
66
+ if (text === "")
67
+ return null;
68
+ let negative = false;
69
+ // Accounting negatives are parenthesised, and a trailing sign is common.
70
+ if (/^\(.*\)$/.test(text)) {
71
+ negative = true;
72
+ text = text.slice(1, -1);
73
+ }
74
+ if (text.endsWith("-")) {
75
+ negative = true;
76
+ text = text.slice(0, -1);
77
+ }
78
+ text = text.replace(/[^\d.,+-]/g, "");
79
+ if (text.startsWith("-")) {
80
+ negative = !negative;
81
+ text = text.slice(1);
82
+ }
83
+ else if (text.startsWith("+"))
84
+ text = text.slice(1);
85
+ const groupSeparator = decimalComma ? "." : ",";
86
+ const decimalSeparator = decimalComma ? "," : ".";
87
+ text = text.split(groupSeparator).join("");
88
+ const parts = text.split(decimalSeparator);
89
+ if (parts.length > 2)
90
+ return null;
91
+ const whole = parts[0] ?? "";
92
+ const fraction = parts[1] ?? "";
93
+ if (!/^\d*$/.test(whole) || !/^\d*$/.test(fraction))
94
+ return null;
95
+ if (whole === "" && fraction === "")
96
+ return null;
97
+ // Pad or truncate to the currency's exponent rather than rounding a float.
98
+ const scaled = `${whole}${fraction.padEnd(decimals, "0").slice(0, decimals)}`;
99
+ const value = Number.parseInt(scaled === "" ? "0" : scaled, 10);
100
+ return negative ? -value : value;
101
+ }
102
+ /**
103
+ * Parse a date, refusing what cannot be read unambiguously.
104
+ *
105
+ * Returns null rather than a guess. The caller turns that into a rejected
106
+ * row with a reason, which is recoverable; a silently wrong month is not.
107
+ */
108
+ export function parseDate(raw, order) {
109
+ const text = raw.trim();
110
+ const iso = /^(\d{4})-(\d{2})-(\d{2})/.exec(text);
111
+ if (iso)
112
+ return `${iso[1]}-${iso[2]}-${iso[3]}`;
113
+ // OFX and several exports use YYYYMMDD, optionally with a time after it.
114
+ const compact = /^(\d{4})(\d{2})(\d{2})/.exec(text);
115
+ if (compact)
116
+ return `${compact[1]}-${compact[2]}-${compact[3]}`;
117
+ const slashed = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/.exec(text);
118
+ if (slashed) {
119
+ if (order === "iso")
120
+ return null;
121
+ const first = Number(slashed[1]);
122
+ const second = Number(slashed[2]);
123
+ let year = Number(slashed[3]);
124
+ if (year < 100)
125
+ year += year < 70 ? 2000 : 1900;
126
+ const day = order === "dmy" ? first : second;
127
+ const month = order === "dmy" ? second : first;
128
+ if (month < 1 || month > 12 || day < 1 || day > 31)
129
+ return null;
130
+ return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
131
+ }
132
+ return null;
133
+ }
134
+ /** True when every slashed date in the file reads the same either way. */
135
+ export function datesAreUnambiguous(values) {
136
+ for (const value of values) {
137
+ const slashed = /^(\d{1,2})[/.-](\d{1,2})[/.-](\d{2,4})$/.exec(value.trim());
138
+ if (!slashed)
139
+ continue;
140
+ const first = Number(slashed[1]);
141
+ const second = Number(slashed[2]);
142
+ // Both readable as a month means the file cannot settle the question.
143
+ if (first <= 12 && second <= 12 && first !== second)
144
+ return false;
145
+ }
146
+ return true;
147
+ }
148
+ const COLUMN_ALIASES = {
149
+ date: ["date", "transaction date", "posted date", "posting date", "value date", "booking date"],
150
+ description: ["description", "details", "narrative", "memo", "payee", "name", "reference text"],
151
+ amount: ["amount", "value", "transaction amount"],
152
+ debit: ["debit", "withdrawal", "withdrawals", "money out", "paid out", "charge"],
153
+ credit: ["credit", "deposit", "deposits", "money in", "paid in"],
154
+ balance: ["balance", "running balance", "closing balance"],
155
+ reference: ["reference", "ref", "transaction id", "id", "cheque number"],
156
+ };
157
+ function findColumn(headers, role, override) {
158
+ if (override !== undefined) {
159
+ const at = headers.findIndex((h) => h.toLowerCase() === override.toLowerCase());
160
+ if (at === -1)
161
+ throw new Error(`the file has no column named "${override}"`);
162
+ return at;
163
+ }
164
+ const aliases = COLUMN_ALIASES[role] ?? [];
165
+ return headers.findIndex((h) => aliases.includes(h.trim().toLowerCase()));
166
+ }
167
+ export function parseStatement(text, options = {}) {
168
+ const format = options.format ?? (/<OFX>|<STMTTRN>/i.test(text) ? "ofx" : "csv");
169
+ const decimals = options.decimals ?? 2;
170
+ const decimalComma = options.decimalComma === true;
171
+ const rejected = [];
172
+ const transactions = [];
173
+ if (format === "ofx") {
174
+ // OFX is SGML with optional closing tags; the transaction blocks are all
175
+ // 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()) {
182
+ 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" });
186
+ continue;
187
+ }
188
+ const name = field(block, "NAME") || field(block, "MEMO");
189
+ transactions.push({
190
+ id: field(block, "FITID") || `ofx-${i + 1}`,
191
+ date,
192
+ description: name,
193
+ amountMinor: amount,
194
+ direction: amount < 0 ? "debit" : "credit",
195
+ reference: field(block, "CHECKNUM") || field(block, "REFNUM"),
196
+ balanceMinor: null,
197
+ });
198
+ }
199
+ }
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");
204
+ }
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(", ")}`);
217
+ }
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(", ")}`);
220
+ }
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";
229
+ }
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
+ });
266
+ }
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
+ };
281
+ }
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
+ };
296
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Per-line tax from an operator's own rate tables.
3
+ *
4
+ * Money is integer minor units throughout — cents, pence, satoshi-free. A
5
+ * tax engine that computes in floating point produces totals that do not add
6
+ * up, and "off by a cent" in an invoice is not a rounding detail, it is a
7
+ * document that fails an audit.
8
+ *
9
+ * Two choices are the caller's and neither has a safe default, so both are
10
+ * explicit: whether listed prices already include tax, and whether rounding
11
+ * happens per line or once per invoice. Jurisdictions differ, the totals
12
+ * differ, and a tool that picked one silently would be wrong half the time.
13
+ */
14
+ export declare const ROUNDING_MODES: readonly ["half-up", "half-even", "down", "up"];
15
+ export type RoundingMode = (typeof ROUNDING_MODES)[number];
16
+ export declare const ROUNDING_SCOPES: readonly ["line", "invoice"];
17
+ export type RoundingScope = (typeof ROUNDING_SCOPES)[number];
18
+ export type TaxRate = {
19
+ readonly code: string;
20
+ /** Basis points: 2000 is 20%. Integer, so the rate itself never rounds. */
21
+ readonly bps: number;
22
+ readonly name?: string;
23
+ /**
24
+ * Charged on the running total including earlier taxes, rather than on the
25
+ * net amount. Quebec's QST historically worked this way, and getting it
26
+ * wrong changes the total rather than a rounding digit.
27
+ */
28
+ readonly compound?: boolean;
29
+ };
30
+ export type TaxLine = {
31
+ readonly id: string;
32
+ /** Minor units. Net of tax unless `pricesIncludeTax` is set. */
33
+ readonly amountMinor: number;
34
+ /** Rate codes applied to this line, in order. */
35
+ readonly taxCodes: ReadonlyArray<string>;
36
+ /** Exempt from tax entirely, which is not the same as a zero rate. */
37
+ readonly exempt?: boolean;
38
+ };
39
+ export type TaxOptions = {
40
+ readonly pricesIncludeTax?: boolean;
41
+ readonly rounding?: RoundingMode;
42
+ readonly scope?: RoundingScope;
43
+ /**
44
+ * B2B cross-border: the customer accounts for the tax, so the invoice
45
+ * carries none. It is reported rather than silently zero, because an
46
+ * invoice that shows no tax for the wrong reason is a compliance problem.
47
+ */
48
+ readonly reverseCharge?: boolean;
49
+ };
50
+ export type TaxLineResult = {
51
+ readonly id: string;
52
+ readonly netMinor: number;
53
+ readonly taxMinor: number;
54
+ readonly grossMinor: number;
55
+ readonly breakdown: ReadonlyArray<{
56
+ readonly code: string;
57
+ readonly bps: number;
58
+ readonly taxMinor: number;
59
+ }>;
60
+ readonly note: string;
61
+ };
62
+ export type TaxResult = {
63
+ readonly lines: ReadonlyArray<TaxLineResult>;
64
+ readonly netMinor: number;
65
+ readonly taxMinor: number;
66
+ readonly grossMinor: number;
67
+ readonly byCode: ReadonlyArray<{
68
+ readonly code: string;
69
+ readonly bps: number;
70
+ readonly taxMinor: number;
71
+ readonly netMinor: number;
72
+ }>;
73
+ readonly reverseCharge: boolean;
74
+ readonly rounding: RoundingMode;
75
+ readonly scope: RoundingScope;
76
+ };
77
+ /** Round a rational to an integer, by the caller's rule. */
78
+ export declare function roundMinor(numerator: number, denominator: number, mode: RoundingMode): number;
79
+ export declare function calculateTax(lines: ReadonlyArray<TaxLine>, rates: ReadonlyArray<TaxRate>, options?: TaxOptions): TaxResult;