@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.
- package/LICENSE +21 -0
- package/README.md +410 -2
- package/dist/discounts.d.mts +33 -0
- package/dist/discounts.mjs +97 -0
- package/dist/errors-DGzIIflf.d.mts +24 -0
- package/dist/errors.d.mts +2 -0
- package/dist/errors.mjs +64 -0
- package/dist/index.d.mts +14 -0
- package/dist/index.mjs +11 -0
- package/dist/internal-p2FS_A4L.mjs +22 -0
- package/dist/lines.d.mts +142 -0
- package/dist/lines.mjs +385 -0
- package/dist/money-aFFtGd0E.mjs +166 -0
- package/dist/money.d.mts +50 -0
- package/dist/money.mjs +2 -0
- package/dist/payments.d.mts +31 -0
- package/dist/payments.mjs +166 -0
- package/dist/ports.d.mts +40 -0
- package/dist/ports.mjs +73 -0
- package/dist/snapshot.d.mts +26 -0
- package/dist/snapshot.mjs +32 -0
- package/dist/testing.d.mts +237 -0
- package/dist/testing.mjs +2318 -0
- package/dist/totals.d.mts +75 -0
- package/dist/totals.mjs +123 -0
- package/dist/types-BzlVbRp1.d.mts +493 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.mjs +46 -0
- package/package.json +91 -3
|
@@ -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 };
|
package/dist/money.d.mts
ADDED
|
@@ -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 };
|
package/dist/ports.d.mts
ADDED
|
@@ -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 };
|