@vibedout/commerce 0.0.0-stage → 0.2.3

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,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,31 @@
1
+ import { n as CommerceProblem } from "./errors-DGzIIflf.mjs";
2
+ import { Q as StartPaymentInput, R as PaymentMethod, c as CommerceLocale, n as AdjustmentKind } from "./types-BzlVbRp1.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, so the provider's page and the order agree
27
+ * to the centavo. It does not check anything; {@link checkStartPayment} does, and a total of zero is a free order that starts no payment.
28
+ */
29
+ declare function paymentRequestFrom(totals: Totals, parts: PaymentRequestParts): StartPaymentInput;
30
+ //#endregion
31
+ export { PaymentRequestParts, checkStartPayment, paymentRequestFrom };
@@ -0,0 +1,166 @@
1
+ import { problem } from "./errors.mjs";
2
+ import { a as isText, i as isRecord, r as isDisplayText } from "./internal-p2FS_A4L.mjs";
3
+ import { a as isCurrencyCode, l as sumMinor, o as isMinorUnits, s as lineTotalMinor } from "./money-aFFtGd0E.mjs";
4
+ import { ADJUSTMENT_KINDS, PAYMENT_METHODS } from "./types.mjs";
5
+ //#region src/payments.ts
6
+ /**
7
+ * The payment request a checkout hands the payments port, and the check a payments port runs on one before it opens a provider's page.
8
+ * The lines and adjustments must add up to the total: a provider charges what the request says, so a request that does not reconcile is
9
+ * refused instead of charged.
10
+ */
11
+ /** The longest title a payment line or adjustment may have: what a provider's page can show. */
12
+ const MAX_TITLE = 200;
13
+ const MAX_METADATA_ENTRIES = 20;
14
+ const MAX_METADATA_VALUE = 500;
15
+ /** A page the provider sends the payer back to: https, or http on the machine itself for development. */
16
+ function isReturnUrl(value) {
17
+ if (typeof value !== "string" || value.length > 2048) return false;
18
+ let url;
19
+ try {
20
+ url = new URL(value);
21
+ } catch {
22
+ return false;
23
+ }
24
+ if (url.username !== "" || url.password !== "") return false;
25
+ if (url.protocol === "https:") return true;
26
+ return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
27
+ }
28
+ /**
29
+ * 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;
30
+ * 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
31
+ * not be more than the lines, whatever the shipping and the tax add.
32
+ */
33
+ function checkStartPayment(input) {
34
+ if (!isRecord(input)) return [problem("invalid_payment", "input", "a payment request is an object")];
35
+ const out = [];
36
+ const add = (code, path, message) => out.push(problem(code, path, message));
37
+ if (!isText(input.reference, 120)) add("invalid_payment", "reference", "reference is the order's id: 1 to 120 printable characters");
38
+ const currency = input.currency;
39
+ if (!isCurrencyCode(currency)) add("invalid_currency", "currency", "currency is an uppercase three-letter ISO 4217 code");
40
+ if (!isReturnUrl(input.successUrl)) add("invalid_payment", "successUrl", "successUrl is an https address (http only on localhost) with no login in it");
41
+ if (!isReturnUrl(input.cancelUrl)) add("invalid_payment", "cancelUrl", "cancelUrl is an https address (http only on localhost) with no login in it");
42
+ if (input.expiresAt !== void 0 && (typeof input.expiresAt !== "number" || !Number.isSafeInteger(input.expiresAt) || input.expiresAt <= 0)) add("invalid_payment", "expiresAt", "expiresAt is milliseconds since the epoch");
43
+ if (input.methods !== void 0) {
44
+ const methods = input.methods;
45
+ if (!Array.isArray(methods) || methods.length === 0 || methods.some((method) => !PAYMENT_METHODS.includes(method))) add("invalid_payment", "methods", `methods is a non-empty list of ${PAYMENT_METHODS.join(", ")}`);
46
+ }
47
+ if (input.payer !== void 0) {
48
+ const payer = input.payer;
49
+ if (!isRecord(payer) || payer.userId !== void 0 && !isText(payer.userId, 120) || payer.email !== void 0 && (!isText(payer.email, 254) || !payer.email.includes("@"))) add("invalid_payment", "payer", "payer is { userId?, email? }");
50
+ }
51
+ if (input.metadata !== void 0) {
52
+ const metadata = input.metadata;
53
+ const entries = isRecord(metadata) ? Object.entries(metadata) : void 0;
54
+ if (!entries || entries.length > MAX_METADATA_ENTRIES || entries.some(([key, value]) => !isText(key, 40) || typeof value !== "string" || value.length > MAX_METADATA_VALUE)) add("invalid_payment", "metadata", `metadata is at most ${MAX_METADATA_ENTRIES} strings under short names`);
55
+ }
56
+ const lineTotals = [];
57
+ if (!Array.isArray(input.lines) || input.lines.length === 0) add("invalid_payment", "lines", "a payment has at least one line");
58
+ else for (const [index, line] of input.lines.entries()) {
59
+ const path = `lines[${index}]`;
60
+ if (!isRecord(line) || !isDisplayText(line.title, MAX_TITLE)) add("invalid_payment", path, "a line has a title");
61
+ else if (line.currency !== currency) add("currency_mismatch", `${path}.currency`, "every line is in the request's currency");
62
+ else try {
63
+ lineTotals.push(lineTotalMinor(line.unitAmountMinor, line.quantity));
64
+ } catch {
65
+ add("invalid_payment", path, "a line has a whole price in minor units and a whole quantity of at least 1");
66
+ }
67
+ }
68
+ const adjustments = input.adjustments === void 0 ? [] : input.adjustments;
69
+ const signed = {
70
+ shipping: [],
71
+ tax: [],
72
+ discount: []
73
+ };
74
+ if (!Array.isArray(adjustments)) add("invalid_payment", "adjustments", "adjustments is a list");
75
+ else for (const [index, adjustment] of adjustments.entries()) {
76
+ const path = `adjustments[${index}]`;
77
+ if (!isRecord(adjustment) || !ADJUSTMENT_KINDS.includes(adjustment.kind) || !isDisplayText(adjustment.title, MAX_TITLE) || !isMinorUnits(adjustment.amountMinor)) add("invalid_payment", path, "an adjustment has a kind (shipping, tax or discount), a title and a whole amount of minor units, zero or more");
78
+ else if (adjustment.currency !== currency) add("currency_mismatch", `${path}.currency`, "every adjustment is in the request's currency");
79
+ else signed[adjustment.kind].push(adjustment.amountMinor);
80
+ }
81
+ if (!isMinorUnits(input.totalMinor)) add("invalid_payment", "totalMinor", "totalMinor is a whole number of minor units");
82
+ if (out.length > 0) return out;
83
+ const code = currency;
84
+ try {
85
+ const items = sumMinor(code, lineTotals);
86
+ const gross = sumMinor(code, [
87
+ items,
88
+ ...signed.shipping,
89
+ ...signed.tax
90
+ ]);
91
+ const discount = sumMinor(code, signed.discount);
92
+ if (discount > items) add("payment_not_reconciled", "adjustments", `the discounts are larger than the items they are taken from (${discount} against ${items})`);
93
+ else if (gross - discount !== input.totalMinor) add("payment_not_reconciled", "totalMinor", `the lines and adjustments come to ${gross - discount} and the total says ${String(input.totalMinor)}`);
94
+ else if (input.totalMinor === 0) add("free_order", "totalMinor", "nothing to charge: a free order is paid without a provider");
95
+ } catch {
96
+ add("payment_not_reconciled", "lines", "the amounts are too large to add exactly");
97
+ }
98
+ return out;
99
+ }
100
+ const DEFAULT_LABELS = {
101
+ "es-MX": {
102
+ shipping: "Envío",
103
+ tax: "Impuestos",
104
+ discount: "Descuento"
105
+ },
106
+ en: {
107
+ shipping: "Shipping",
108
+ tax: "Tax",
109
+ discount: "Discount"
110
+ }
111
+ };
112
+ /**
113
+ * The request for `totals`: its lines, and its shipping, added tax and discounts as adjustments, so the provider's page and the order agree
114
+ * to the centavo. It does not check anything; {@link checkStartPayment} does, and a total of zero is a free order that starts no payment.
115
+ */
116
+ function paymentRequestFrom(totals, parts) {
117
+ const labels = {
118
+ ...DEFAULT_LABELS[parts.locale ?? "es-MX"],
119
+ ...parts.labels
120
+ };
121
+ const currency = totals.currency;
122
+ const adjustments = [];
123
+ if (totals.shippingMinor > 0) adjustments.push({
124
+ kind: "shipping",
125
+ title: totals.shipping?.title ?? labels.shipping,
126
+ amountMinor: totals.shippingMinor,
127
+ currency
128
+ });
129
+ if (totals.taxMinor > 0) {
130
+ const named = totals.taxBreakdown.filter((part) => !part.included).map((part) => part.title).join(" + ");
131
+ adjustments.push({
132
+ kind: "tax",
133
+ title: named !== "" && named.length <= MAX_TITLE ? named : labels.tax,
134
+ amountMinor: totals.taxMinor,
135
+ currency
136
+ });
137
+ }
138
+ for (const discount of totals.discounts) if (discount.amountMinor > 0) adjustments.push({
139
+ kind: "discount",
140
+ title: discount.title || labels.discount,
141
+ amountMinor: discount.amountMinor,
142
+ currency
143
+ });
144
+ return {
145
+ reference: parts.reference,
146
+ lines: totals.lines.map((line) => ({
147
+ ref: line.ref,
148
+ title: line.title,
149
+ quantity: line.quantity,
150
+ unitAmountMinor: line.unitAmountMinor,
151
+ currency,
152
+ ...line.taxCode === void 0 ? {} : { taxCode: line.taxCode }
153
+ })),
154
+ adjustments,
155
+ totalMinor: totals.totalMinor,
156
+ currency,
157
+ ...parts.payer ? { payer: parts.payer } : {},
158
+ ...parts.methods ? { methods: parts.methods } : {},
159
+ successUrl: parts.successUrl,
160
+ cancelUrl: parts.cancelUrl,
161
+ ...parts.expiresAt === void 0 ? {} : { expiresAt: parts.expiresAt },
162
+ ...parts.metadata ? { metadata: parts.metadata } : {}
163
+ };
164
+ }
165
+ //#endregion
166
+ export { checkStartPayment, paymentRequestFrom };
@@ -0,0 +1,40 @@
1
+ import { n as CommerceProblem } from "./errors-DGzIIflf.mjs";
2
+ import { H as PortProviders, V as PortName } from "./types-BzlVbRp1.mjs";
3
+ //#region src/ports.d.ts
4
+ type MethodsOf<K extends PortName> = Extract<keyof PortProviders[K], string>;
5
+ /**
6
+ * The methods every provider of each port has. The type below fails to compile when this list and {@link OPTIONAL_PORT_METHODS} miss one or
7
+ * name one that is gone.
8
+ */
9
+ declare const PORT_METHODS: {
10
+ readonly "line-source": readonly ["resolve"];
11
+ readonly stock: readonly ["availability", "hold", "commit", "release"];
12
+ readonly payments: readonly ["start"];
13
+ readonly tax: readonly ["compute"];
14
+ readonly "shipping-rates": readonly ["quote"];
15
+ readonly discount: readonly ["apply"];
16
+ };
17
+ /**
18
+ * The methods a provider may leave out (added after a port's first version, so an older provider still is one). A caller checks that it is
19
+ * there before it calls it; when it is there, it must be a function.
20
+ */
21
+ declare const OPTIONAL_PORT_METHODS: {
22
+ readonly "line-source": readonly ["hold", "commit", "release"];
23
+ readonly stock: readonly ["fulfil", "uncommit"];
24
+ readonly payments: readonly ["cancel"];
25
+ readonly tax: readonly [];
26
+ readonly "shipping-rates": readonly [];
27
+ readonly discount: readonly [];
28
+ };
29
+ /** `never` while {@link PORT_METHODS} and {@link OPTIONAL_PORT_METHODS} list every method of every port between them. */
30
+ type UnlistedPortMethods = { [K in PortName]: Exclude<MethodsOf<K>, (typeof PORT_METHODS)[K][number] | (typeof OPTIONAL_PORT_METHODS)[K][number]>; }[PortName];
31
+ /** The port names, in the order the engine lists them (`WORKER_PORTS`). */
32
+ declare const PORT_NAMES: PortName[];
33
+ declare const isPortName: (value: unknown) => value is PortName;
34
+ /**
35
+ * The methods `value` lacks to be a provider of `port`, the optional ones it has that are not functions, and the ones missing from a set it
36
+ * has only part of (`hold` without `commit` and `release`). An empty list means it has them all (it does not run them).
37
+ */
38
+ declare function checkPortProvider(port: PortName, value: unknown, path?: string): CommerceProblem[];
39
+ //#endregion
40
+ export { OPTIONAL_PORT_METHODS, PORT_METHODS, PORT_NAMES, UnlistedPortMethods, checkPortProvider, isPortName };
package/dist/ports.mjs ADDED
@@ -0,0 +1,73 @@
1
+ import { problem } from "./errors.mjs";
2
+ //#region src/ports.ts
3
+ /**
4
+ * What a provider of each port must have, as data a feature's test can check: `checkPortProvider("stock", fxStock)` lists the methods the
5
+ * export is missing. The compiler already checks a provider typed as `PortProviders["stock"]`; this is for the one that is not (a provider
6
+ * assembled by a factory, or written in plain JavaScript) and for the registry's own test.
7
+ */
8
+ /**
9
+ * The methods every provider of each port has. The type below fails to compile when this list and {@link OPTIONAL_PORT_METHODS} miss one or
10
+ * name one that is gone.
11
+ */
12
+ const PORT_METHODS = {
13
+ "line-source": ["resolve"],
14
+ stock: [
15
+ "availability",
16
+ "hold",
17
+ "commit",
18
+ "release"
19
+ ],
20
+ payments: ["start"],
21
+ tax: ["compute"],
22
+ "shipping-rates": ["quote"],
23
+ discount: ["apply"]
24
+ };
25
+ /**
26
+ * The methods a provider may leave out (added after a port's first version, so an older provider still is one). A caller checks that it is
27
+ * there before it calls it; when it is there, it must be a function.
28
+ */
29
+ const OPTIONAL_PORT_METHODS = {
30
+ "line-source": [
31
+ "hold",
32
+ "commit",
33
+ "release"
34
+ ],
35
+ stock: ["fulfil", "uncommit"],
36
+ payments: ["cancel"],
37
+ tax: [],
38
+ "shipping-rates": [],
39
+ discount: []
40
+ };
41
+ /** Optional methods that come as a set: a provider has every one of a set or none (a line source that holds its places commits and releases them). */
42
+ const TOGETHER = { "line-source": [[
43
+ "hold",
44
+ "commit",
45
+ "release"
46
+ ]] };
47
+ /** The port names, in the order the engine lists them (`WORKER_PORTS`). */
48
+ const PORT_NAMES = Object.keys(PORT_METHODS);
49
+ const isPortName = (value) => typeof value === "string" && Object.hasOwn(PORT_METHODS, value);
50
+ /**
51
+ * The methods `value` lacks to be a provider of `port`, the optional ones it has that are not functions, and the ones missing from a set it
52
+ * has only part of (`hold` without `commit` and `release`). An empty list means it has them all (it does not run them).
53
+ */
54
+ function checkPortProvider(port, value, path = port) {
55
+ if (!isPortName(port)) return [problem("invalid_port", path, `${String(port)} is not a port`)];
56
+ if (typeof value !== "object" && typeof value !== "function") return [problem("invalid_port", path, `a ${port} provider is an object with ${PORT_METHODS[port].join(", ")}`)];
57
+ if (value === null) return [problem("invalid_port", path, `a ${port} provider is an object with ${PORT_METHODS[port].join(", ")}`)];
58
+ const methods = value;
59
+ const missing = PORT_METHODS[port].filter((method) => typeof methods[method] !== "function").map((method) => problem("invalid_port", `${path}.${method}`, `a ${port} provider has a ${method} method`));
60
+ const wrong = OPTIONAL_PORT_METHODS[port].filter((method) => methods[method] !== void 0 && typeof methods[method] !== "function").map((method) => problem("invalid_port", `${path}.${method}`, `${method} is optional, and a function when a ${port} provider has it`));
61
+ const partial = (TOGETHER[port] ?? []).flatMap((set) => {
62
+ const absent = set.filter((method) => methods[method] === void 0);
63
+ if (absent.length === 0 || absent.length === set.length) return [];
64
+ return absent.map((method) => problem("invalid_port", `${path}.${method}`, `a ${port} provider with any of ${set.join(", ")} has all of them`));
65
+ });
66
+ return [
67
+ ...missing,
68
+ ...wrong,
69
+ ...partial
70
+ ];
71
+ }
72
+ //#endregion
73
+ export { OPTIONAL_PORT_METHODS, PORT_METHODS, PORT_NAMES, checkPortProvider, isPortName };
@@ -0,0 +1,26 @@
1
+ import { M as LineSnapshot } from "./types-BzlVbRp1.mjs";
2
+ //#region src/snapshot.d.ts
3
+ declare const SNAPSHOT_KEYS: {
4
+ /** The stock-keeping unit: the code a discount rule names and a person reads on a packing slip. */
5
+ readonly sku: "sku";
6
+ /** The id of the product the line is a variant of, in the source's own ids: a discount rule can cover every variant of a product. */
7
+ readonly productId: "productId";
8
+ /** The tax class the goods are in (`standard`, `zero`, `exempt`), which a tax provider maps to a rate. Not the SAT product key (`ResolvedLine.taxCode`). */
9
+ readonly taxClass: "taxClass";
10
+ };
11
+ type SnapshotKey = keyof typeof SNAPSHOT_KEYS;
12
+ /** The longest value of any of these keys. */
13
+ declare const MAX_SNAPSHOT_TEXT = 120;
14
+ /**
15
+ * The text a source wrote under one of the {@link SNAPSHOT_KEYS}, or `undefined` when it wrote none, or something that is not a short string of
16
+ * printable characters. A reader never trusts the rest of the snapshot: this is the only way a feature in this family reads it.
17
+ *
18
+ * ```ts
19
+ * const sku = snapshotText(line, "sku"); // a rule for "TAG-CARD-GREVIEW" matches this line when it is that
20
+ * ```
21
+ */
22
+ declare function snapshotText(line: {
23
+ snapshot?: LineSnapshot;
24
+ }, key: SnapshotKey): string | undefined;
25
+ //#endregion
26
+ export { MAX_SNAPSHOT_TEXT, SNAPSHOT_KEYS, SnapshotKey, snapshotText };
@@ -0,0 +1,32 @@
1
+ import { a as isText } from "./internal-p2FS_A4L.mjs";
2
+ //#region src/snapshot.ts
3
+ /**
4
+ * What a line source writes in a line's `snapshot` so that the features that price and tax the line can find it without importing the source
5
+ * (0.2.2). The snapshot is free-form (a source adds what its receipt needs); these keys are the few that other features read, so they are fixed
6
+ * here and a source that sells goods writes them. A feature that finds none of them still works: a discount rule for a SKU matches nothing and a
7
+ * tax provider uses its default class.
8
+ */
9
+ const SNAPSHOT_KEYS = {
10
+ /** The stock-keeping unit: the code a discount rule names and a person reads on a packing slip. */
11
+ sku: "sku",
12
+ /** The id of the product the line is a variant of, in the source's own ids: a discount rule can cover every variant of a product. */
13
+ productId: "productId",
14
+ /** The tax class the goods are in (`standard`, `zero`, `exempt`), which a tax provider maps to a rate. Not the SAT product key (`ResolvedLine.taxCode`). */
15
+ taxClass: "taxClass"
16
+ };
17
+ /** The longest value of any of these keys. */
18
+ const MAX_SNAPSHOT_TEXT = 120;
19
+ /**
20
+ * The text a source wrote under one of the {@link SNAPSHOT_KEYS}, or `undefined` when it wrote none, or something that is not a short string of
21
+ * printable characters. A reader never trusts the rest of the snapshot: this is the only way a feature in this family reads it.
22
+ *
23
+ * ```ts
24
+ * const sku = snapshotText(line, "sku"); // a rule for "TAG-CARD-GREVIEW" matches this line when it is that
25
+ * ```
26
+ */
27
+ function snapshotText(line, key) {
28
+ const value = line.snapshot?.[SNAPSHOT_KEYS[key]];
29
+ return isText(value, 120) ? value : void 0;
30
+ }
31
+ //#endregion
32
+ export { MAX_SNAPSHOT_TEXT, SNAPSHOT_KEYS, snapshotText };