@vibedout/commerce 0.0.0-stage → 0.2.4

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/dist/lines.mjs ADDED
@@ -0,0 +1,385 @@
1
+ import { problem } from "./errors.mjs";
2
+ import { a as isText, i as isRecord, n as UNSAFE, r as isDisplayText, t as SUBJECT_TYPE } from "./internal-p2FS_A4L.mjs";
3
+ import { a as isCurrencyCode, o as isMinorUnits } from "./money-aFFtGd0E.mjs";
4
+ import { FULFILMENTS, LINE_AVAILABILITIES } from "./types.mjs";
5
+ //#region src/lines.ts
6
+ /**
7
+ * Checks at the two edges of a line source: what the browser asks for, and what a source answers. Both return a list of problems and never
8
+ * throw, because the first is hostile input and the second is code another feature wrote (or a project edited). A checkout runs
9
+ * {@link validateAsks} on the request body and {@link resolveLines} on the way to a price, so a wrong line stops there and not in the total.
10
+ */
11
+ const DEFAULT_ASK_LIMITS = {
12
+ maxLines: 50,
13
+ maxQuantity: 99,
14
+ maxRefLength: 120,
15
+ maxOptions: 10
16
+ };
17
+ /** The longest ref or stock subject id there is: `maxRefLength` of {@link AskLimits} is cut to this, so a line can always be checked. */
18
+ const MAX_REF_LENGTH = 1024;
19
+ /** What a line's snapshot may weigh as JSON, in bytes: it is copied into every order line. */
20
+ const MAX_SNAPSHOT_BYTES = 2048;
21
+ const MAX_TITLE_LENGTH = 200;
22
+ const MAX_OPTION_KEY_LENGTH = 40;
23
+ const MAX_OPTION_VALUE_LENGTH = 200;
24
+ const MAX_SNAPSHOT_DEPTH = 6;
25
+ const FORBIDDEN_KEYS = /* @__PURE__ */ new Set([
26
+ "__proto__",
27
+ "constructor",
28
+ "prototype"
29
+ ]);
30
+ const OPTION_KEY = /^[A-Za-z][A-Za-z0-9_.-]*$/;
31
+ const TAX_CODE = /^[A-Za-z0-9]{1,20}$/;
32
+ /** A non-empty string of printable characters with no space at either end. */
33
+ function isPlainText(value, maxLength) {
34
+ return isText(value, maxLength) && value === value.trim();
35
+ }
36
+ /** A line source's key: dotted lowercase words (`catalog.variant`), what `block.json` allows and the pattern pack's registry finds. */
37
+ const SOURCE_KEY = /^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$/;
38
+ const MAX_SOURCE_KEY_LENGTH = 80;
39
+ /** The name of an ask in a repeat check: a ref with its options in a fixed order (and its source, in a cart). */
40
+ const askKey = (source, ref, options) => `${source ?? ""}\u0000${ref}\u0000${JSON.stringify(Object.entries(options ?? {}).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0))}`;
41
+ /** The one reader behind {@link validateAsks} and {@link validateCartAsks}: the list, each ask's shape, and the repeats. */
42
+ function readAsks(input, limits, how) {
43
+ const max = {
44
+ ...DEFAULT_ASK_LIMITS,
45
+ ...limits
46
+ };
47
+ max.maxRefLength = Math.min(max.maxRefLength, MAX_REF_LENGTH);
48
+ if (!Array.isArray(input)) return {
49
+ ok: false,
50
+ problems: [problem("invalid_asks", "asks", "asks must be a list")]
51
+ };
52
+ if (input.length === 0) return {
53
+ ok: false,
54
+ problems: [problem("invalid_asks", "asks", "ask for at least one thing")]
55
+ };
56
+ if (input.length > max.maxLines) return {
57
+ ok: false,
58
+ problems: [problem("invalid_asks", "asks", `at most ${max.maxLines} lines`)]
59
+ };
60
+ const problems = [];
61
+ const asks = [];
62
+ const seen = /* @__PURE__ */ new Set();
63
+ for (const [index, raw] of input.entries()) {
64
+ const path = `asks[${index}]`;
65
+ if (!isRecord(raw)) {
66
+ problems.push(problem("invalid_asks", path, `each ask is an object with ${how.cart ? "a source, " : ""}a ref and a quantity`));
67
+ continue;
68
+ }
69
+ const { ref, quantity, options, source } = raw;
70
+ let valid = true;
71
+ if (how.cart) {
72
+ if (typeof source !== "string" || source.length > MAX_SOURCE_KEY_LENGTH || !SOURCE_KEY.test(source)) {
73
+ problems.push(problem("invalid_source", `${path}.source`, "source is the key of a line source: dotted lowercase words (catalog.variant)"));
74
+ valid = false;
75
+ } else if (how.find && how.find(source) === void 0) {
76
+ problems.push(problem("unknown_source", `${path}.source`, `nothing sells through ${source}`));
77
+ valid = false;
78
+ }
79
+ }
80
+ if (!isPlainText(ref, max.maxRefLength)) {
81
+ problems.push(problem("invalid_ref", `${path}.ref`, `ref is 1 to ${max.maxRefLength} printable characters with no space at either end`));
82
+ valid = false;
83
+ }
84
+ if (typeof quantity !== "number" || !Number.isSafeInteger(quantity) || quantity < 1 || quantity > max.maxQuantity) {
85
+ problems.push(problem("invalid_quantity", `${path}.quantity`, `quantity is a whole number from 1 to ${max.maxQuantity}`));
86
+ valid = false;
87
+ }
88
+ let cleanOptions;
89
+ if (options !== void 0) {
90
+ const read = readOptions(options, max, `${path}.options`);
91
+ if (Array.isArray(read)) {
92
+ problems.push(...read);
93
+ valid = false;
94
+ } else cleanOptions = read;
95
+ }
96
+ if (!valid) continue;
97
+ const key = askKey(how.cart ? source : void 0, ref, cleanOptions);
98
+ if (seen.has(key)) {
99
+ problems.push(problem("duplicate_ref", `${path}.ref`, "the same thing is asked for twice: send one line with the total quantity"));
100
+ continue;
101
+ }
102
+ seen.add(key);
103
+ const ask = {
104
+ ref,
105
+ quantity,
106
+ ...cleanOptions ? { options: cleanOptions } : {}
107
+ };
108
+ asks.push(how.cart ? {
109
+ source,
110
+ ...ask
111
+ } : ask);
112
+ }
113
+ return problems.length > 0 ? {
114
+ ok: false,
115
+ problems
116
+ } : {
117
+ ok: true,
118
+ asks
119
+ };
120
+ }
121
+ /**
122
+ * Reads the `asks` of a request body. What comes out has exactly `ref`, `quantity` and (when given) `options`: anything else the browser sent,
123
+ * a `price` above all, is dropped, never read. A repeated ref is refused (the client sums its own quantities).
124
+ */
125
+ function validateAsks(input, limits = {}) {
126
+ const read = readAsks(input, limits, { cart: false });
127
+ return read.ok ? {
128
+ ok: true,
129
+ asks: read.asks
130
+ } : read;
131
+ }
132
+ /**
133
+ * Reads the lines of a cart: a list of `{ source, ref, quantity, options? }` (the same limits as {@link validateAsks}: 1 to 50 lines,
134
+ * quantities 1 to 99, refs of 1 to 120 printable characters) and gives back exactly those fields, in the order sent. It is the check a cart's
135
+ * `complete` runs on its lines BEFORE any price is asked and any place is held, so a hostile or stale cart costs nothing and holds nothing.
136
+ *
137
+ * - `invalid_source`: the source is not a key (dotted lowercase words, at most 80 characters), so it is never looked up;
138
+ * - `unknown_source`: with {@link CartAskOptions.find}, nothing is registered under the key;
139
+ * - `duplicate_ref`: the same source, ref and options twice (the same ref under two sources, or with other options, is two lines);
140
+ * - what {@link validateAsks} refuses for a ref, a quantity or the options, at the same paths (`asks[2].quantity`).
141
+ *
142
+ * Anything else in an ask, a price above all, is dropped and never read. Whether the thing exists and can be bought is the source's answer,
143
+ * not this check's: price the result with {@link resolveCart}.
144
+ *
145
+ * ```ts
146
+ * const read = validateCartAsks(cart.lines, { find: (key) => { const found = linePort<LineSource>(key); return found.ok ? found.port : undefined; } });
147
+ * if (!read.ok) return refuse(read.problems);
148
+ * const priced = await resolveCart(find, ctx, read.asks);
149
+ * ```
150
+ */
151
+ function validateCartAsks(input, options = {}) {
152
+ const { find, ...limits } = options;
153
+ return readAsks(input, limits, {
154
+ cart: true,
155
+ find
156
+ });
157
+ }
158
+ function readOptions(value, max, path) {
159
+ if (!isRecord(value)) return [problem("invalid_options", path, "options is an object of strings")];
160
+ const entries = Object.entries(value);
161
+ if (entries.length > max.maxOptions) return [problem("invalid_options", path, `at most ${max.maxOptions} options`)];
162
+ const out = {};
163
+ for (const [key, option] of entries) {
164
+ if (key.length > MAX_OPTION_KEY_LENGTH || !OPTION_KEY.test(key) || FORBIDDEN_KEYS.has(key)) return [problem("invalid_options", `${path}.${key.slice(0, 20)}`, "an option name is letters, digits, dot, dash or underscore, starting with a letter")];
165
+ if (typeof option !== "string" || option.length > MAX_OPTION_VALUE_LENGTH || UNSAFE.test(option)) return [problem("invalid_options", `${path}.${key}`, `an option value is a string of at most ${MAX_OPTION_VALUE_LENGTH} printable characters`)];
166
+ out[key] = option;
167
+ }
168
+ return out;
169
+ }
170
+ /** Why a value cannot be written to JSON and read back the same, or `undefined` when it can. */
171
+ function notJson(value, depth, path) {
172
+ if (value === null || typeof value === "string" || typeof value === "boolean") return void 0;
173
+ if (typeof value === "number") return Number.isFinite(value) ? void 0 : "a number that is not finite";
174
+ if (depth >= MAX_SNAPSHOT_DEPTH) return `nested more than ${MAX_SNAPSHOT_DEPTH} levels`;
175
+ if (typeof value !== "object") return `a ${typeof value}`;
176
+ if (path.has(value)) return "a cycle";
177
+ const proto = Object.getPrototypeOf(value);
178
+ if (!Array.isArray(value) && proto !== Object.prototype && proto !== null) return "an object that is not plain";
179
+ path.add(value);
180
+ for (const item of Array.isArray(value) ? value : Object.values(value)) {
181
+ const reason = notJson(item, depth + 1, path);
182
+ if (reason) return reason;
183
+ }
184
+ path.delete(value);
185
+ }
186
+ /** The problems with one line, judged on its own: its shape and the values it may hold. Does not look at the ask it answers. */
187
+ function checkResolvedLine(line, path = "line") {
188
+ if (!isRecord(line)) return [problem("invalid_line", path, "a line is an object")];
189
+ const out = [];
190
+ const add = (code, field, message) => out.push(problem(code, `${path}.${field}`, message));
191
+ if (!isPlainText(line.ref, 1024)) add("invalid_ref", "ref", "ref is the ask's ref");
192
+ if (!isDisplayText(line.title, MAX_TITLE_LENGTH)) add("invalid_title", "title", `title is 1 to ${MAX_TITLE_LENGTH} printable characters`);
193
+ if (typeof line.quantity !== "number" || !Number.isSafeInteger(line.quantity) || line.quantity < 1) add("invalid_quantity", "quantity", "quantity is a whole number of at least 1");
194
+ if (!isMinorUnits(line.unitAmountMinor)) add("invalid_price", "unitAmountMinor", "a price is a whole number of minor units, zero or more");
195
+ if (!isCurrencyCode(line.currency)) add("invalid_currency", "currency", "currency is an uppercase three-letter ISO 4217 code");
196
+ if (!LINE_AVAILABILITIES.includes(line.availability)) add("invalid_availability", "availability", `availability is one of ${LINE_AVAILABILITIES.join(", ")}`);
197
+ else if (line.availability === "not_found" && line.unitAmountMinor !== 0) add("invalid_price", "unitAmountMinor", "a line that is not found has no price: it is 0");
198
+ if (line.maxQuantity !== void 0 && (typeof line.maxQuantity !== "number" || !Number.isSafeInteger(line.maxQuantity) || line.maxQuantity < 1)) add("invalid_max_quantity", "maxQuantity", "maxQuantity is a whole number of at least 1 when given (a line with none left is sold_out)");
199
+ if (line.taxCode !== void 0 && (typeof line.taxCode !== "string" || !TAX_CODE.test(line.taxCode))) add("invalid_line", "taxCode", "taxCode is 1 to 20 letters or digits when given");
200
+ if (typeof line.taxIncluded !== "boolean") add("invalid_line", "taxIncluded", "taxIncluded is true or false");
201
+ if (!FULFILMENTS.includes(line.fulfilment)) add("invalid_fulfilment", "fulfilment", `fulfilment is one of ${FULFILMENTS.join(", ")}`);
202
+ if (line.stock !== void 0) {
203
+ const stock = line.stock;
204
+ if (!isRecord(stock) || !isText(stock.subjectType, 80) || !SUBJECT_TYPE.test(stock.subjectType) || !isPlainText(stock.subjectId, 1024)) add("invalid_stock_subject", "stock", "stock is { subjectType, subjectId }: a dotted lowercase type (catalog.variant) and printable text");
205
+ }
206
+ if (!isRecord(line.snapshot)) add("invalid_snapshot", "snapshot", "snapshot is an object");
207
+ else {
208
+ const reason = notJson(line.snapshot, 0, /* @__PURE__ */ new Set());
209
+ if (reason) add("invalid_snapshot", "snapshot", `snapshot must survive JSON unchanged: it holds ${reason}`);
210
+ else if (new TextEncoder().encode(JSON.stringify(line.snapshot)).length > 2048) add("invalid_snapshot", "snapshot", `snapshot is more than ${MAX_SNAPSHOT_BYTES} bytes as JSON`);
211
+ }
212
+ return out;
213
+ }
214
+ /** The problems with a source's whole answer to `asks`: one line per ask, in order, each valid and echoing its ask's ref and quantity. */
215
+ function checkResolvedLines(asks, lines, path = "lines") {
216
+ if (!Array.isArray(lines)) return [problem("invalid_lines", path, "a source returns a list of lines")];
217
+ if (lines.length !== asks.length) return [problem("line_count_mismatch", path, `${asks.length} asked, ${lines.length} returned: a source answers every ask, in order`)];
218
+ const out = [];
219
+ for (const [index, line] of lines.entries()) {
220
+ const at = `${path}[${index}]`;
221
+ const shape = checkResolvedLine(line, at);
222
+ out.push(...shape);
223
+ if (!isRecord(line)) continue;
224
+ const ask = asks[index];
225
+ if (line.ref !== ask.ref) out.push(problem("line_ref_mismatch", `${at}.ref`, `answered ${String(line.ref)} to ${ask.ref}: lines come back in the order asked`));
226
+ if (line.quantity !== ask.quantity) out.push(problem("line_quantity_mismatch", `${at}.quantity`, "a source echoes the quantity asked and never changes it"));
227
+ }
228
+ return out;
229
+ }
230
+ /**
231
+ * Asks a source for the lines and checks the answer, so nothing a source gets wrong reaches a total. The source is given frozen copies, so
232
+ * one that sorts or edits its input in place fails loudly instead of corrupting the caller's order. A source that throws is not caught: that
233
+ * is a fault, not a problem with the answer.
234
+ */
235
+ async function resolveLines(source, ctx, asks) {
236
+ const given = Object.freeze(asks.map((ask) => Object.freeze({
237
+ ref: ask.ref,
238
+ quantity: ask.quantity,
239
+ ...ask.options ? { options: Object.freeze({ ...ask.options }) } : {}
240
+ })));
241
+ const lines = await source.resolve(ctx, given);
242
+ const problems = checkResolvedLines(asks, lines);
243
+ return problems.length > 0 ? {
244
+ ok: false,
245
+ problems
246
+ } : {
247
+ ok: true,
248
+ lines: [...lines]
249
+ };
250
+ }
251
+ /** No currency (ISO 4217 `XXX`): the price of a line that is not sold here, which is 0 and never charged. */
252
+ const NO_CURRENCY = "XXX";
253
+ /** `text` cut to `max` UTF-16 units with an ellipsis, never in the middle of a character. */
254
+ function cut(text, max) {
255
+ if (text.length <= max) return text;
256
+ let out = "";
257
+ for (const character of text) {
258
+ if (out.length + character.length > max - 1) break;
259
+ out += character;
260
+ }
261
+ return `${out}\u2026`;
262
+ }
263
+ /** The line for an ask whose source is not there (a feature that was removed, a key nobody registered): not sold, like a ref no source has. */
264
+ const notSoldHere = (ask, source) => ({
265
+ source,
266
+ ref: ask.ref,
267
+ title: cut(ask.ref, MAX_TITLE_LENGTH),
268
+ quantity: ask.quantity,
269
+ unitAmountMinor: 0,
270
+ currency: NO_CURRENCY,
271
+ availability: "not_found",
272
+ taxIncluded: true,
273
+ fulfilment: "none",
274
+ snapshot: {}
275
+ });
276
+ /**
277
+ * Prices a cart whose lines name different sources: each source is asked once, for its own asks in the cart's order (through
278
+ * {@link resolveLines}, so its answer is checked and it is given frozen copies without the `source` key), the sources at the same time, and
279
+ * the lines come back one per ask in the cart's order, each with its `source`.
280
+ *
281
+ * - An ask whose source `find` does not have (a feature that was removed, a key nobody registered, an empty key) is a `not_found` line with a
282
+ * price of 0 in no currency (`XXX`), as a ref no source has: the cart shows it as no longer sold and totals the rest (`partitionLines`).
283
+ * - A source that answers wrong makes the whole answer `{ ok: false, problems }`, listed source by source; a problem with one line has the cart's
284
+ * line as its path (`lines[3].unitAmountMinor`), one with a source's whole answer (`line_count_mismatch`) the path `lines`, and every message
285
+ * starts with the source's key. A source that throws is not caught.
286
+ * - The asks are not checked here: check them where they come in (`validateAsks` for the ref, quantity and options; the registry's lookup
287
+ * finds only a key that was registered). A line not sold here shows its ref as its title.
288
+ *
289
+ * ```ts
290
+ * const cart = await resolveCart((key) => { const found = linePort<LineSource>(key); return found.ok ? found.port : undefined; }, ctx, asks);
291
+ * if (!cart.ok) throw new Error(cart.problems[0]?.message);
292
+ * const { chargeable, blocked } = partitionLines(cart.lines);
293
+ * ```
294
+ */
295
+ async function resolveCart(find, ctx, asks) {
296
+ const groups = /* @__PURE__ */ new Map();
297
+ for (const [index, ask] of asks.entries()) {
298
+ const key = typeof ask.source === "string" ? ask.source : "";
299
+ groups.set(key, [...groups.get(key) ?? [], index]);
300
+ }
301
+ const answers = await Promise.all([...groups].map(async ([key, indexes]) => {
302
+ const found = key === "" ? void 0 : find(key);
303
+ if (typeof found?.resolve !== "function") return {
304
+ key,
305
+ indexes,
306
+ result: void 0
307
+ };
308
+ return {
309
+ key,
310
+ indexes,
311
+ result: await resolveLines(found, ctx, indexes.map((index) => asks[index]))
312
+ };
313
+ }));
314
+ const lines = new Array(asks.length);
315
+ const problems = [];
316
+ for (const { key, indexes, result } of answers) if (result === void 0) for (const index of indexes) lines[index] = notSoldHere(asks[index], key);
317
+ else if (!result.ok) for (const found of result.problems) {
318
+ const path = found.path.replace(/^lines\[(\d+)\]/, (_match, at) => `lines[${indexes[Number(at)]}]`);
319
+ problems.push(problem(found.code, path, `${key}: ${found.message}`));
320
+ }
321
+ else result.lines.forEach((resolved, at) => {
322
+ lines[indexes[at]] = {
323
+ ...resolved,
324
+ source: key
325
+ };
326
+ });
327
+ return problems.length > 0 ? {
328
+ ok: false,
329
+ problems
330
+ } : {
331
+ ok: true,
332
+ lines
333
+ };
334
+ }
335
+ /**
336
+ * Whether a source holds its own places (`hold`, `commit` and `release` are all there), so a checkout holds its asks beside the stock. A source
337
+ * with only some of the three is `false`, and a checkout would not hold its places: `checkPortProvider` and the line-source harness (its `holds`
338
+ * check) report such a source.
339
+ */
340
+ function holdsCapacity(source) {
341
+ return typeof source.hold === "function" && typeof source.commit === "function" && typeof source.release === "function";
342
+ }
343
+ /** Why a (valid) line cannot be charged now, or `undefined` when it can. */
344
+ function blockedReason(line) {
345
+ if (line.availability !== "available") return line.availability;
346
+ if (line.maxQuantity !== void 0 && line.quantity > line.maxQuantity) return "over_max_quantity";
347
+ }
348
+ function blockedProblem(line, path) {
349
+ const reason = blockedReason(line);
350
+ if (!reason) return void 0;
351
+ return problem(reason, path, reason === "over_max_quantity" ? `only ${line.maxQuantity} of ${line.ref} can be taken` : `${line.ref} is ${reason.replace("_", " ")}`);
352
+ }
353
+ /**
354
+ * Splits valid lines into those that can be charged and those that cannot, with the reason: a cart shows the second group ("sold out", "only 2
355
+ * left") and totals the first. The lines keep their type, so a cart's lines keep their `source`.
356
+ */
357
+ function partitionLines(lines) {
358
+ const chargeable = [];
359
+ const blocked = [];
360
+ for (const [index, line] of lines.entries()) {
361
+ const reason = blockedReason(line);
362
+ if (reason) blocked.push({
363
+ index,
364
+ line,
365
+ reason
366
+ });
367
+ else chargeable.push(line);
368
+ }
369
+ return {
370
+ chargeable,
371
+ blocked
372
+ };
373
+ }
374
+ /**
375
+ * The lines to hold for these (resolved or priced) lines: each line that names a stock subject, with its quantity. Hold with this and not with
376
+ * the `ref`: a hold on a subject inventory does not know is taken as untracked, always available, with no error.
377
+ */
378
+ function holdLinesFrom(lines) {
379
+ return lines.flatMap((line) => line.stock ? [{
380
+ subject: { ...line.stock },
381
+ quantity: line.quantity
382
+ }] : []);
383
+ }
384
+ //#endregion
385
+ export { DEFAULT_ASK_LIMITS, MAX_REF_LENGTH, MAX_SNAPSHOT_BYTES, blockedProblem, blockedReason, checkResolvedLine, checkResolvedLines, holdLinesFrom, holdsCapacity, partitionLines, resolveCart, resolveLines, validateAsks, validateCartAsks };
@@ -0,0 +1,166 @@
1
+ import { CommerceError } from "./errors.mjs";
2
+ //#region ../money/src/index.ts
3
+ /** Thrown for every amount this package refuses. A caller maps it onto its own error family. */
4
+ var MoneyError = class extends Error {
5
+ code;
6
+ /** What was being read, as the caller named it (`"totalPrice"`). */
7
+ field;
8
+ constructor(code, message, field) {
9
+ super(message);
10
+ this.name = "MoneyError";
11
+ this.code = code;
12
+ this.field = field;
13
+ }
14
+ };
15
+ const CURRENCY = /^[A-Z]{3}$/;
16
+ /** A whole, non-negative number of minor units with an uppercase three-letter currency. Zero is a valid amount here; use {@link assertMoney} to refuse it. */
17
+ function isMoney(value) {
18
+ if (typeof value !== "object" || value === null) return false;
19
+ const v = value;
20
+ return typeof v.amountMinor === "number" && Number.isSafeInteger(v.amountMinor) && v.amountMinor >= 0 && typeof v.currency === "string" && CURRENCY.test(v.currency);
21
+ }
22
+ /** The total of `items`, which must all be valid {@link Money} in one currency. `currency` names the total of an empty list. */
23
+ function sumMoney(items, currency) {
24
+ const code = items[0]?.currency ?? currency;
25
+ if (code === void 0) throw new MoneyError("invalid_money", "sumMoney needs a currency for an empty list");
26
+ let total = 0;
27
+ for (const [index, item] of items.entries()) {
28
+ if (!isMoney(item)) throw new MoneyError("invalid_money", `sumMoney: items[${index}] is not a valid amount`, `items[${index}]`);
29
+ if (item.currency !== code) throw new MoneyError("currency_mismatch", `cannot add ${item.currency} to ${code}`);
30
+ total += item.amountMinor;
31
+ }
32
+ if (!Number.isSafeInteger(total)) throw new MoneyError("out_of_range", "the total is too large to hold exactly");
33
+ return {
34
+ amountMinor: total,
35
+ currency: code
36
+ };
37
+ }
38
+ //#endregion
39
+ //#region src/money.ts
40
+ /**
41
+ * Minor-unit arithmetic for an order. Whole numbers only: a price, a quantity and every amount that crosses a port is a safe integer, a
42
+ * currency is an uppercase ISO 4217 code, and two currencies are never added. The money type and the overflow-safe sum are
43
+ * `@vibedout/money`'s; this file adds what an order needs on top (a line total, a subtraction that cannot go below zero, a rate applied in
44
+ * whole numbers). The totals of a whole order are `./totals`.
45
+ */
46
+ /** A whole, non-negative number of minor units that a JavaScript number holds exactly. */
47
+ function isMinorUnits(value) {
48
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
49
+ }
50
+ /** An uppercase three-letter ISO 4217 code (`MXN`). */
51
+ function isCurrencyCode(value) {
52
+ return isMoney({
53
+ amountMinor: 0,
54
+ currency: value
55
+ });
56
+ }
57
+ /** The price of `quantity` of something that costs `unitAmountMinor` each. Throws {@link CommerceError} for anything but whole numbers or a result a number cannot hold. */
58
+ function lineTotalMinor(unitAmountMinor, quantity) {
59
+ if (!isMinorUnits(unitAmountMinor)) throw new CommerceError("invalid_price", "a price is a whole number of minor units, zero or more", "unitAmountMinor");
60
+ if (!Number.isSafeInteger(quantity) || quantity < 1) throw new CommerceError("invalid_quantity", "a quantity is a whole number of at least 1", "quantity");
61
+ const total = unitAmountMinor * quantity;
62
+ if (!Number.isSafeInteger(total)) throw new CommerceError("out_of_range", "the line total is too large to hold exactly", "unitAmountMinor");
63
+ return total;
64
+ }
65
+ /**
66
+ * The sum of amounts in one currency, checked: every amount a whole number of minor units, and a result a number holds exactly. It is
67
+ * `sumMoney` of `@vibedout/money`, which owns the overflow rule. Throws {@link CommerceError}.
68
+ */
69
+ function sumMinor(currency, amounts) {
70
+ if (!isCurrencyCode(currency)) throw new CommerceError("invalid_currency", "a currency is an uppercase three-letter ISO 4217 code", "currency");
71
+ try {
72
+ return sumMoney(amounts.map((amountMinor) => ({
73
+ amountMinor,
74
+ currency
75
+ })), currency).amountMinor;
76
+ } catch (error) {
77
+ if (error instanceof MoneyError) throw new CommerceError(error.code === "out_of_range" ? "out_of_range" : "invalid_price", error.message);
78
+ throw error;
79
+ }
80
+ }
81
+ /** `from` minus `amount`, never below zero: a discount cannot be larger than what it discounts. Throws {@link CommerceError}. */
82
+ function subtractMinor(from, amount) {
83
+ if (!isMinorUnits(from) || !isMinorUnits(amount)) throw new CommerceError("out_of_range", "both amounts are whole numbers of minor units, zero or more");
84
+ if (amount > from) throw new CommerceError("discount_exceeds_subtotal", `cannot take ${amount} from ${from}`);
85
+ return from - amount;
86
+ }
87
+ const ROUNDINGS = [
88
+ "half-up",
89
+ "down",
90
+ "up"
91
+ ];
92
+ /** The highest rate there is: 1000%, 100000 basis points. A discount is at most 10000 (100%); a tax can be more (Mexican IEPS on tobacco is 160%). */
93
+ const MAX_RATE_BP = 1e5;
94
+ /** A rate in basis points: 16% is 1600, a whole number from 0 to {@link MAX_RATE_BP}. */
95
+ const isRateBp = (value) => typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 1e5;
96
+ /** `amount * numerator / denominator`, rounded, with BigInt for the product so a large amount does not lose a centavo to a float. */
97
+ function scaled(amountMinor, numerator, denominator, rounding) {
98
+ if (!isMinorUnits(amountMinor)) throw new CommerceError("out_of_range", "an amount is a whole number of minor units, zero or more", "amountMinor");
99
+ if (!ROUNDINGS.includes(rounding)) throw new CommerceError("invalid_adjustment", `rounding is one of ${ROUNDINGS.join(", ")}`, "rounding");
100
+ const product = BigInt(amountMinor) * BigInt(numerator);
101
+ const divisor = BigInt(denominator);
102
+ const quotient = product / divisor;
103
+ const remainder = product % divisor;
104
+ const result = (rounding === "up" ? remainder > 0n : rounding === "half-up" ? remainder * 2n >= divisor : false) ? quotient + 1n : quotient;
105
+ if (result > BigInt(Number.MAX_SAFE_INTEGER)) throw new CommerceError("out_of_range", "the result is too large to hold exactly");
106
+ return Number(result);
107
+ }
108
+ /**
109
+ * `rateBp` of an amount, in whole minor units: `basisPointsOf(10_000, 1600)` is 1600 (16% of 100.00). Use this and not `amount * 0.16`: a float
110
+ * rounds a centavo the wrong way now and then (`(1.005).toFixed(2)` is "1.00"). A rate over 10000 (100%) is a tax such as IEPS; a result larger
111
+ * than a number holds exactly is refused. Throws {@link CommerceError}.
112
+ */
113
+ function basisPointsOf(amountMinor, rateBp, rounding = "half-up") {
114
+ if (!isRateBp(rateBp)) throw new CommerceError("invalid_adjustment", "a rate is a whole number of basis points from 0 to 100000 (16% is 1600)", "rateBp");
115
+ return scaled(amountMinor, rateBp, 1e4, rounding);
116
+ }
117
+ /**
118
+ * The tax that is already inside a gross price: a shelf price of 116.00 with IVA at 16% holds 16.00 (`taxIncludedIn(11_600, 1600)`). It is
119
+ * `gross * rate / (10000 + rate)`, so the net price is `subtractMinor(gross, tax)`. Throws {@link CommerceError}.
120
+ */
121
+ function taxIncludedIn(grossMinor, rateBp, rounding = "half-up") {
122
+ if (!isRateBp(rateBp)) throw new CommerceError("invalid_adjustment", "a rate is a whole number of basis points from 0 to 100000 (16% is 1600)", "rateBp");
123
+ return scaled(grossMinor, rateBp, 1e4 + rateBp, rounding);
124
+ }
125
+ /**
126
+ * `totalMinor` split over `weights` in proportion, in whole minor units that add up to `totalMinor` exactly: a discount spread over the lines
127
+ * of an order (each line's total as its weight) for an invoice that states each line's discount (a CFDI does). Largest remainder: each share
128
+ * is its exact proportion rounded down, and the centavos left go one each to the largest fractions, the earlier line first on a tie. When
129
+ * the total is not more than the weights add up to, no share is more than its weight, so a discount never takes a line below zero; a weight
130
+ * of 0 gets 0. The product is BigInt, so a large amount loses nothing. `@vibedout/payments` spreads a discount over the lines of a provider's
131
+ * page by the same rule, so the invoice and the page agree line by line when the discount says nothing about its lines; a discount that does
132
+ * (`AppliedDiscount.lines`, 0.2.2) is used as it says.
133
+ *
134
+ * ```ts
135
+ * allocateMinor(1000, [3000, 1000]); // [750, 250]: 10.00 off a 30.00 and a 10.00 line
136
+ * allocateMinor(100, [1, 1, 1]); // [34, 33, 33]
137
+ * ```
138
+ *
139
+ * Throws {@link CommerceError}: `out_of_range` for a total or a weight that is not a whole number of minor units, `invalid_adjustment` for no
140
+ * weights, or something to split over weights that are all zero.
141
+ */
142
+ function allocateMinor(totalMinor, weights) {
143
+ if (!isMinorUnits(totalMinor)) throw new CommerceError("out_of_range", "the amount to split is a whole number of minor units, zero or more", "totalMinor");
144
+ if (!Array.isArray(weights) || weights.length === 0) throw new CommerceError("invalid_adjustment", "an amount is split over at least one weight", "weights");
145
+ for (const [index, weight] of weights.entries()) if (!isMinorUnits(weight)) throw new CommerceError("out_of_range", "a weight is a whole number of minor units, zero or more", `weights[${index}]`);
146
+ const sum = weights.reduce((total, weight) => total + BigInt(weight), 0n);
147
+ if (sum === 0n) {
148
+ if (totalMinor === 0) return weights.map(() => 0);
149
+ throw new CommerceError("invalid_adjustment", `cannot split ${totalMinor} over weights that are all zero`, "weights");
150
+ }
151
+ const total = BigInt(totalMinor);
152
+ const shares = weights.map((weight) => BigInt(weight) * total / sum);
153
+ const order = weights.map((weight, index) => ({
154
+ index,
155
+ rest: BigInt(weight) * total % sum
156
+ })).sort((a, b) => a.rest === b.rest ? a.index - b.index : a.rest > b.rest ? -1 : 1);
157
+ let left = total - shares.reduce((sumOfShares, share) => sumOfShares + share, 0n);
158
+ for (const { index } of order) {
159
+ if (left === 0n) break;
160
+ shares[index] = shares[index] + 1n;
161
+ left -= 1n;
162
+ }
163
+ return shares.map((share) => Number(share));
164
+ }
165
+ //#endregion
166
+ export { isCurrencyCode as a, subtractMinor as c, basisPointsOf as i, sumMinor as l, ROUNDINGS as n, isMinorUnits as o, allocateMinor as r, lineTotalMinor as s, MAX_RATE_BP as t, taxIncludedIn as u };
@@ -0,0 +1,50 @@
1
+ //#region src/money.d.ts
2
+ /** A whole, non-negative number of minor units that a JavaScript number holds exactly. */
3
+ declare function isMinorUnits(value: unknown): value is number;
4
+ /** An uppercase three-letter ISO 4217 code (`MXN`). */
5
+ declare function isCurrencyCode(value: unknown): value is string;
6
+ /** The price of `quantity` of something that costs `unitAmountMinor` each. Throws {@link CommerceError} for anything but whole numbers or a result a number cannot hold. */
7
+ declare function lineTotalMinor(unitAmountMinor: number, quantity: number): number;
8
+ /**
9
+ * The sum of amounts in one currency, checked: every amount a whole number of minor units, and a result a number holds exactly. It is
10
+ * `sumMoney` of `@vibedout/money`, which owns the overflow rule. Throws {@link CommerceError}.
11
+ */
12
+ declare function sumMinor(currency: string, amounts: readonly number[]): number;
13
+ /** `from` minus `amount`, never below zero: a discount cannot be larger than what it discounts. Throws {@link CommerceError}. */
14
+ declare function subtractMinor(from: number, amount: number): number;
15
+ declare const ROUNDINGS: readonly ["half-up", "down", "up"];
16
+ /** What to do with the fraction of a centavo: `half-up` is the usual rule (0.5 goes up), `down` and `up` are for a rule that says so. */
17
+ type Rounding = (typeof ROUNDINGS)[number];
18
+ /** The highest rate there is: 1000%, 100000 basis points. A discount is at most 10000 (100%); a tax can be more (Mexican IEPS on tobacco is 160%). */
19
+ declare const MAX_RATE_BP = 100000;
20
+ /**
21
+ * `rateBp` of an amount, in whole minor units: `basisPointsOf(10_000, 1600)` is 1600 (16% of 100.00). Use this and not `amount * 0.16`: a float
22
+ * rounds a centavo the wrong way now and then (`(1.005).toFixed(2)` is "1.00"). A rate over 10000 (100%) is a tax such as IEPS; a result larger
23
+ * than a number holds exactly is refused. Throws {@link CommerceError}.
24
+ */
25
+ declare function basisPointsOf(amountMinor: number, rateBp: number, rounding?: Rounding): number;
26
+ /**
27
+ * The tax that is already inside a gross price: a shelf price of 116.00 with IVA at 16% holds 16.00 (`taxIncludedIn(11_600, 1600)`). It is
28
+ * `gross * rate / (10000 + rate)`, so the net price is `subtractMinor(gross, tax)`. Throws {@link CommerceError}.
29
+ */
30
+ declare function taxIncludedIn(grossMinor: number, rateBp: number, rounding?: Rounding): number;
31
+ /**
32
+ * `totalMinor` split over `weights` in proportion, in whole minor units that add up to `totalMinor` exactly: a discount spread over the lines
33
+ * of an order (each line's total as its weight) for an invoice that states each line's discount (a CFDI does). Largest remainder: each share
34
+ * is its exact proportion rounded down, and the centavos left go one each to the largest fractions, the earlier line first on a tie. When
35
+ * the total is not more than the weights add up to, no share is more than its weight, so a discount never takes a line below zero; a weight
36
+ * of 0 gets 0. The product is BigInt, so a large amount loses nothing. `@vibedout/payments` spreads a discount over the lines of a provider's
37
+ * page by the same rule, so the invoice and the page agree line by line when the discount says nothing about its lines; a discount that does
38
+ * (`AppliedDiscount.lines`, 0.2.2) is used as it says.
39
+ *
40
+ * ```ts
41
+ * allocateMinor(1000, [3000, 1000]); // [750, 250]: 10.00 off a 30.00 and a 10.00 line
42
+ * allocateMinor(100, [1, 1, 1]); // [34, 33, 33]
43
+ * ```
44
+ *
45
+ * Throws {@link CommerceError}: `out_of_range` for a total or a weight that is not a whole number of minor units, `invalid_adjustment` for no
46
+ * weights, or something to split over weights that are all zero.
47
+ */
48
+ declare function allocateMinor(totalMinor: number, weights: readonly number[]): number[];
49
+ //#endregion
50
+ export { MAX_RATE_BP, ROUNDINGS, Rounding, allocateMinor, basisPointsOf, isCurrencyCode, isMinorUnits, lineTotalMinor, subtractMinor, sumMinor, taxIncludedIn };
package/dist/money.mjs ADDED
@@ -0,0 +1,2 @@
1
+ import { a as isCurrencyCode, c as subtractMinor, i as basisPointsOf, l as sumMinor, n as ROUNDINGS, o as isMinorUnits, r as allocateMinor, s as lineTotalMinor, t as MAX_RATE_BP, u as taxIncludedIn } from "./money-aFFtGd0E.mjs";
2
+ export { MAX_RATE_BP, ROUNDINGS, allocateMinor, basisPointsOf, isCurrencyCode, isMinorUnits, lineTotalMinor, subtractMinor, sumMinor, taxIncludedIn };
@@ -0,0 +1,32 @@
1
+ import { n as CommerceProblem } from "./errors-DGzIIflf.mjs";
2
+ import { $ as StartPaymentInput, c as CommerceLocale, n as AdjustmentKind, z as PaymentMethod } from "./types-BUyLunnB.mjs";
3
+ import { Totals } from "./totals.mjs";
4
+ //#region src/payments.d.ts
5
+ /**
6
+ * The problems with a request to start a payment. A payments port runs this first and answers `invalid_request` when the list is not empty;
7
+ * a total of zero is `free_order` (the order is paid without a provider), not a charge. The discounts come off the lines: together they may
8
+ * not be more than the lines, whatever the shipping and the tax add.
9
+ */
10
+ declare function checkStartPayment(input: unknown): CommerceProblem[];
11
+ interface PaymentRequestParts {
12
+ /** The order's id. */
13
+ reference: string;
14
+ successUrl: string;
15
+ cancelUrl: string;
16
+ payer?: StartPaymentInput["payer"];
17
+ methods?: readonly PaymentMethod[];
18
+ expiresAt?: number;
19
+ metadata?: Readonly<Record<string, string>>;
20
+ /** The names of the three adjustments on the provider's page. Default by `locale`. */
21
+ labels?: Partial<Record<AdjustmentKind, string>>;
22
+ /** Picks the default labels. Default `es-MX`. */
23
+ locale?: CommerceLocale;
24
+ }
25
+ /**
26
+ * The request for `totals`: its lines, and its shipping, added tax and discounts as adjustments (each discount with the lines it came off,
27
+ * 0.2.4), so the provider's page and the order agree to the centavo, line by line. It does not check anything; {@link checkStartPayment} does, and a total of
28
+ * zero is a free order that starts no payment.
29
+ */
30
+ declare function paymentRequestFrom(totals: Totals, parts: PaymentRequestParts): StartPaymentInput;
31
+ //#endregion
32
+ export { PaymentRequestParts, checkStartPayment, paymentRequestFrom };