@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/LICENSE +21 -0
- package/README.md +439 -2
- package/dist/discounts.d.mts +63 -0
- package/dist/discounts.mjs +185 -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 +32 -0
- package/dist/payments.mjs +193 -0
- package/dist/ports.d.mts +40 -0
- package/dist/ports.mjs +73 -0
- package/dist/snapshot.d.mts +41 -0
- package/dist/snapshot.mjs +46 -0
- package/dist/testing.d.mts +237 -0
- package/dist/testing.mjs +2318 -0
- package/dist/totals.d.mts +82 -0
- package/dist/totals.mjs +130 -0
- package/dist/types-BUyLunnB.d.mts +503 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.mjs +46 -0
- package/package.json +91 -3
|
@@ -0,0 +1,193 @@
|
|
|
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 { checkLineDiscounts } from "./discounts.mjs";
|
|
5
|
+
import { ADJUSTMENT_KINDS, PAYMENT_METHODS } from "./types.mjs";
|
|
6
|
+
//#region src/payments.ts
|
|
7
|
+
/**
|
|
8
|
+
* 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.
|
|
9
|
+
* 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
|
|
10
|
+
* refused instead of charged.
|
|
11
|
+
*/
|
|
12
|
+
/** The longest title a payment line or adjustment may have: what a provider's page can show. */
|
|
13
|
+
const MAX_TITLE = 200;
|
|
14
|
+
const MAX_METADATA_ENTRIES = 20;
|
|
15
|
+
const MAX_METADATA_VALUE = 500;
|
|
16
|
+
/** A page the provider sends the payer back to: https, or http on the machine itself for development. */
|
|
17
|
+
function isReturnUrl(value) {
|
|
18
|
+
if (typeof value !== "string" || value.length > 2048) return false;
|
|
19
|
+
let url;
|
|
20
|
+
try {
|
|
21
|
+
url = new URL(value);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
if (url.username !== "" || url.password !== "") return false;
|
|
26
|
+
if (url.protocol === "https:") return true;
|
|
27
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* 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;
|
|
31
|
+
* 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
|
|
32
|
+
* not be more than the lines, whatever the shipping and the tax add.
|
|
33
|
+
*/
|
|
34
|
+
function checkStartPayment(input) {
|
|
35
|
+
if (!isRecord(input)) return [problem("invalid_payment", "input", "a payment request is an object")];
|
|
36
|
+
const out = [];
|
|
37
|
+
const add = (code, path, message) => out.push(problem(code, path, message));
|
|
38
|
+
if (!isText(input.reference, 120)) add("invalid_payment", "reference", "reference is the order's id: 1 to 120 printable characters");
|
|
39
|
+
const currency = input.currency;
|
|
40
|
+
if (!isCurrencyCode(currency)) add("invalid_currency", "currency", "currency is an uppercase three-letter ISO 4217 code");
|
|
41
|
+
if (!isReturnUrl(input.successUrl)) add("invalid_payment", "successUrl", "successUrl is an https address (http only on localhost) with no login in it");
|
|
42
|
+
if (!isReturnUrl(input.cancelUrl)) add("invalid_payment", "cancelUrl", "cancelUrl is an https address (http only on localhost) with no login in it");
|
|
43
|
+
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");
|
|
44
|
+
if (input.methods !== void 0) {
|
|
45
|
+
const methods = input.methods;
|
|
46
|
+
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(", ")}`);
|
|
47
|
+
}
|
|
48
|
+
if (input.payer !== void 0) {
|
|
49
|
+
const payer = input.payer;
|
|
50
|
+
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? }");
|
|
51
|
+
}
|
|
52
|
+
if (input.metadata !== void 0) {
|
|
53
|
+
const metadata = input.metadata;
|
|
54
|
+
const entries = isRecord(metadata) ? Object.entries(metadata) : void 0;
|
|
55
|
+
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`);
|
|
56
|
+
}
|
|
57
|
+
const lineTotals = [];
|
|
58
|
+
if (!Array.isArray(input.lines) || input.lines.length === 0) add("invalid_payment", "lines", "a payment has at least one line");
|
|
59
|
+
else for (const [index, line] of input.lines.entries()) {
|
|
60
|
+
const path = `lines[${index}]`;
|
|
61
|
+
if (!isRecord(line) || !isDisplayText(line.title, MAX_TITLE)) add("invalid_payment", path, "a line has a title");
|
|
62
|
+
else if (line.currency !== currency) add("currency_mismatch", `${path}.currency`, "every line is in the request's currency");
|
|
63
|
+
else try {
|
|
64
|
+
lineTotals.push(lineTotalMinor(line.unitAmountMinor, line.quantity));
|
|
65
|
+
} catch {
|
|
66
|
+
add("invalid_payment", path, "a line has a whole price in minor units and a whole quantity of at least 1");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const adjustments = input.adjustments === void 0 ? [] : input.adjustments;
|
|
70
|
+
const signed = {
|
|
71
|
+
shipping: [],
|
|
72
|
+
tax: [],
|
|
73
|
+
discount: []
|
|
74
|
+
};
|
|
75
|
+
if (!Array.isArray(adjustments)) add("invalid_payment", "adjustments", "adjustments is a list");
|
|
76
|
+
else for (const [index, adjustment] of adjustments.entries()) {
|
|
77
|
+
const path = `adjustments[${index}]`;
|
|
78
|
+
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");
|
|
79
|
+
else if (adjustment.currency !== currency) add("currency_mismatch", `${path}.currency`, "every adjustment is in the request's currency");
|
|
80
|
+
else signed[adjustment.kind].push(adjustment.amountMinor);
|
|
81
|
+
}
|
|
82
|
+
if (!isMinorUnits(input.totalMinor)) add("invalid_payment", "totalMinor", "totalMinor is a whole number of minor units");
|
|
83
|
+
if (out.length > 0) return out;
|
|
84
|
+
if (Array.isArray(input.adjustments) && Array.isArray(input.lines)) {
|
|
85
|
+
const lines = input.lines;
|
|
86
|
+
const named = input.adjustments.map((adjustment, index) => {
|
|
87
|
+
if (adjustment.lines !== void 0 && adjustment.kind !== "discount") add("invalid_payment", `adjustments[${index}].lines`, "only a discount says which lines it came off");
|
|
88
|
+
return adjustment.kind === "discount" && adjustment.lines !== void 0 ? {
|
|
89
|
+
title: adjustment.title,
|
|
90
|
+
currency: adjustment.currency,
|
|
91
|
+
amountMinor: adjustment.amountMinor,
|
|
92
|
+
lines: adjustment.lines
|
|
93
|
+
} : {
|
|
94
|
+
title: adjustment.title,
|
|
95
|
+
currency: adjustment.currency,
|
|
96
|
+
amountMinor: 0
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
for (const found of checkLineDiscounts(lines, named, "adjustments")) out.push(found.code === "discount_exceeds_subtotal" ? {
|
|
100
|
+
...found,
|
|
101
|
+
path: found.path.replace(/^adjustments\./, "")
|
|
102
|
+
} : found);
|
|
103
|
+
if (out.length > 0) return out;
|
|
104
|
+
}
|
|
105
|
+
const code = currency;
|
|
106
|
+
try {
|
|
107
|
+
const items = sumMinor(code, lineTotals);
|
|
108
|
+
const gross = sumMinor(code, [
|
|
109
|
+
items,
|
|
110
|
+
...signed.shipping,
|
|
111
|
+
...signed.tax
|
|
112
|
+
]);
|
|
113
|
+
const discount = sumMinor(code, signed.discount);
|
|
114
|
+
if (discount > items) add("payment_not_reconciled", "adjustments", `the discounts are larger than the items they are taken from (${discount} against ${items})`);
|
|
115
|
+
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)}`);
|
|
116
|
+
else if (input.totalMinor === 0) add("free_order", "totalMinor", "nothing to charge: a free order is paid without a provider");
|
|
117
|
+
} catch {
|
|
118
|
+
add("payment_not_reconciled", "lines", "the amounts are too large to add exactly");
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
const DEFAULT_LABELS = {
|
|
123
|
+
"es-MX": {
|
|
124
|
+
shipping: "Envío",
|
|
125
|
+
tax: "Impuestos",
|
|
126
|
+
discount: "Descuento"
|
|
127
|
+
},
|
|
128
|
+
en: {
|
|
129
|
+
shipping: "Shipping",
|
|
130
|
+
tax: "Tax",
|
|
131
|
+
discount: "Discount"
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* The request for `totals`: its lines, and its shipping, added tax and discounts as adjustments (each discount with the lines it came off,
|
|
136
|
+
* 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
|
|
137
|
+
* zero is a free order that starts no payment.
|
|
138
|
+
*/
|
|
139
|
+
function paymentRequestFrom(totals, parts) {
|
|
140
|
+
const labels = {
|
|
141
|
+
...DEFAULT_LABELS[parts.locale ?? "es-MX"],
|
|
142
|
+
...parts.labels
|
|
143
|
+
};
|
|
144
|
+
const currency = totals.currency;
|
|
145
|
+
const adjustments = [];
|
|
146
|
+
if (totals.shippingMinor > 0) adjustments.push({
|
|
147
|
+
kind: "shipping",
|
|
148
|
+
title: totals.shipping?.title ?? labels.shipping,
|
|
149
|
+
amountMinor: totals.shippingMinor,
|
|
150
|
+
currency
|
|
151
|
+
});
|
|
152
|
+
if (totals.taxMinor > 0) {
|
|
153
|
+
const named = totals.taxBreakdown.filter((part) => !part.included).map((part) => part.title).join(" + ");
|
|
154
|
+
adjustments.push({
|
|
155
|
+
kind: "tax",
|
|
156
|
+
title: named !== "" && named.length <= MAX_TITLE ? named : labels.tax,
|
|
157
|
+
amountMinor: totals.taxMinor,
|
|
158
|
+
currency
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
for (const discount of totals.discounts) if (discount.amountMinor > 0) adjustments.push({
|
|
162
|
+
kind: "discount",
|
|
163
|
+
title: discount.title || labels.discount,
|
|
164
|
+
amountMinor: discount.amountMinor,
|
|
165
|
+
currency,
|
|
166
|
+
...discount.lines ? { lines: discount.lines.map((part) => ({
|
|
167
|
+
index: part.index,
|
|
168
|
+
amountMinor: part.amountMinor
|
|
169
|
+
})) } : {}
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
reference: parts.reference,
|
|
173
|
+
lines: totals.lines.map((line) => ({
|
|
174
|
+
ref: line.ref,
|
|
175
|
+
title: line.title,
|
|
176
|
+
quantity: line.quantity,
|
|
177
|
+
unitAmountMinor: line.unitAmountMinor,
|
|
178
|
+
currency,
|
|
179
|
+
...line.taxCode === void 0 ? {} : { taxCode: line.taxCode }
|
|
180
|
+
})),
|
|
181
|
+
adjustments,
|
|
182
|
+
totalMinor: totals.totalMinor,
|
|
183
|
+
currency,
|
|
184
|
+
...parts.payer ? { payer: parts.payer } : {},
|
|
185
|
+
...parts.methods ? { methods: parts.methods } : {},
|
|
186
|
+
successUrl: parts.successUrl,
|
|
187
|
+
cancelUrl: parts.cancelUrl,
|
|
188
|
+
...parts.expiresAt === void 0 ? {} : { expiresAt: parts.expiresAt },
|
|
189
|
+
...parts.metadata ? { metadata: parts.metadata } : {}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
//#endregion
|
|
193
|
+
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 PortName, U as PortProviders } from "./types-BUyLunnB.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,41 @@
|
|
|
1
|
+
import { N as LineSnapshot } from "./types-BUyLunnB.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
|
+
/** The weight of one unit in grams (a number, not text: read it with {@link snapshotWeightG}). The shipping feature adds it up to weigh the parcel a cart's rates are quoted for. */
|
|
11
|
+
readonly weightG: "weightG";
|
|
12
|
+
};
|
|
13
|
+
type SnapshotKey = keyof typeof SNAPSHOT_KEYS;
|
|
14
|
+
/** The keys that hold text; `weightG` holds a number. */
|
|
15
|
+
type SnapshotTextKey = Exclude<SnapshotKey, "weightG">;
|
|
16
|
+
/** The longest value of any of these keys. */
|
|
17
|
+
declare const MAX_SNAPSHOT_TEXT = 120;
|
|
18
|
+
/**
|
|
19
|
+
* 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
|
|
20
|
+
* printable characters. A reader never trusts the rest of the snapshot: this is the only way a feature in this family reads it.
|
|
21
|
+
*
|
|
22
|
+
* ```ts
|
|
23
|
+
* const sku = snapshotText(line, "sku"); // a rule for "TAG-CARD-GREVIEW" matches this line when it is that
|
|
24
|
+
* ```
|
|
25
|
+
*/
|
|
26
|
+
declare function snapshotText(line: {
|
|
27
|
+
snapshot?: LineSnapshot;
|
|
28
|
+
}, key: SnapshotTextKey): string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* The grams one unit of the line weighs, when its source wrote them under `weightG` (0.2.4), else 0: a missing, negative or non-numeric value weighs nothing, so a
|
|
31
|
+
* source that does not know the weight never makes a parcel heavier by accident.
|
|
32
|
+
*
|
|
33
|
+
* ```ts
|
|
34
|
+
* const grams = lines.reduce((sum, line) => sum + snapshotWeightG(line) * line.quantity, 0);
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
declare function snapshotWeightG(line: {
|
|
38
|
+
snapshot?: LineSnapshot;
|
|
39
|
+
}): number;
|
|
40
|
+
//#endregion
|
|
41
|
+
export { MAX_SNAPSHOT_TEXT, SNAPSHOT_KEYS, SnapshotKey, SnapshotTextKey, snapshotText, snapshotWeightG };
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
/** The weight of one unit in grams (a number, not text: read it with {@link snapshotWeightG}). The shipping feature adds it up to weigh the parcel a cart's rates are quoted for. */
|
|
17
|
+
weightG: "weightG"
|
|
18
|
+
};
|
|
19
|
+
/** The longest value of any of these keys. */
|
|
20
|
+
const MAX_SNAPSHOT_TEXT = 120;
|
|
21
|
+
/**
|
|
22
|
+
* 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
|
|
23
|
+
* printable characters. A reader never trusts the rest of the snapshot: this is the only way a feature in this family reads it.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* const sku = snapshotText(line, "sku"); // a rule for "TAG-CARD-GREVIEW" matches this line when it is that
|
|
27
|
+
* ```
|
|
28
|
+
*/
|
|
29
|
+
function snapshotText(line, key) {
|
|
30
|
+
const value = line.snapshot?.[SNAPSHOT_KEYS[key]];
|
|
31
|
+
return isText(value, 120) ? value : void 0;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The grams one unit of the line weighs, when its source wrote them under `weightG` (0.2.4), else 0: a missing, negative or non-numeric value weighs nothing, so a
|
|
35
|
+
* source that does not know the weight never makes a parcel heavier by accident.
|
|
36
|
+
*
|
|
37
|
+
* ```ts
|
|
38
|
+
* const grams = lines.reduce((sum, line) => sum + snapshotWeightG(line) * line.quantity, 0);
|
|
39
|
+
* ```
|
|
40
|
+
*/
|
|
41
|
+
function snapshotWeightG(line) {
|
|
42
|
+
const value = line.snapshot?.[SNAPSHOT_KEYS.weightG];
|
|
43
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
export { MAX_SNAPSHOT_TEXT, SNAPSHOT_KEYS, snapshotText, snapshotWeightG };
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { n as CommerceProblem } from "./errors-DGzIIflf.mjs";
|
|
2
|
+
import { $ as StartPaymentInput, B as PaymentsPort, D as LineAvailability, P as LineSource, V as PortContext, d as DiscountInput, et as StartPaymentResult, f as DiscountPort, nt as StockPort, rt as StockSubject, st as TaxPort, u as Destination, w as HoldingLineSource } from "./types-BUyLunnB.mjs";
|
|
3
|
+
//#region src/testing/context.d.ts
|
|
4
|
+
/** 2026-10-08 12:00 UTC: a fixed "now" for tests, so a hold that expires does so when the test says. */
|
|
5
|
+
declare const TEST_NOW: number;
|
|
6
|
+
/**
|
|
7
|
+
* A {@link PortContext} for a test double: a fixed clock, no environment, and a `db` whose every method throws, so a double that reaches for
|
|
8
|
+
* the database fails loudly. A port that really uses D1 is tested with the project's own `env.DB` instead.
|
|
9
|
+
*/
|
|
10
|
+
declare function testContext(overrides?: Partial<PortContext>): PortContext;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/testing/tools.d.ts
|
|
13
|
+
/** The few matchers the suites use; vitest's `expect` fits. */
|
|
14
|
+
type TestExpect = (value: unknown) => {
|
|
15
|
+
toBe(expected: unknown): void;
|
|
16
|
+
toEqual(expected: unknown): void;
|
|
17
|
+
};
|
|
18
|
+
/** What a conformance suite needs from a test runner: pass `{ describe, it, expect }` from vitest. */
|
|
19
|
+
interface TestTools {
|
|
20
|
+
describe(name: string, fn: () => void): void;
|
|
21
|
+
it(name: string, fn: () => unknown, timeout?: number): void;
|
|
22
|
+
expect: TestExpect;
|
|
23
|
+
}
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/testing/discount-conformance.d.ts
|
|
26
|
+
interface DiscountConformanceOptions {
|
|
27
|
+
/** Orders the provider's rules apply to, each named for the failure message. At least one. */
|
|
28
|
+
scenarios: readonly {
|
|
29
|
+
name: string;
|
|
30
|
+
input: DiscountInput;
|
|
31
|
+
}[];
|
|
32
|
+
/** An order that earns no discount: the answer's `applied` is empty. Optional. */
|
|
33
|
+
plain?: DiscountInput;
|
|
34
|
+
/** The context for a given clock. Default {@link testContext}; a provider that reads D1 passes one with the project's `env.DB`. */
|
|
35
|
+
context?: (now: number) => PortContext | Promise<PortContext>;
|
|
36
|
+
}
|
|
37
|
+
declare function runDiscountConformance(make: () => DiscountPort | Promise<DiscountPort>, tools: TestTools, options: DiscountConformanceOptions): void;
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/testing/fake-discount.d.ts
|
|
40
|
+
interface FlatDiscountOptions {
|
|
41
|
+
/** Percent off a line, in basis points: 10% is 1000. Default 1000. */
|
|
42
|
+
percentBp?: number;
|
|
43
|
+
/** A line gets the discount from this quantity. Default 5. */
|
|
44
|
+
fromQuantity?: number;
|
|
45
|
+
/** The title of the discount. Default `Volume discount`. */
|
|
46
|
+
title?: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A discount port for tests: `percentBp` off every line of at least `fromQuantity`, with the lines it came off named. It knows no code, so a
|
|
50
|
+
* code a buyer typed is `rejected` as `unknown`. The reference `runDiscountConformance` is written from.
|
|
51
|
+
*/
|
|
52
|
+
declare function createFlatDiscount(options?: FlatDiscountOptions): DiscountPort;
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/testing/fake-tax.d.ts
|
|
55
|
+
interface FlatTaxOptions {
|
|
56
|
+
/** The one rate, in basis points: 16% is 1600. Default 1600. */
|
|
57
|
+
rateBp?: number;
|
|
58
|
+
/** The tax's name in the breakdown. Default `IVA`. */
|
|
59
|
+
code?: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* A tax port for tests: one rate for everything, worked per line on what is paid (the line after its discount: `lineDiscountsMinor` when it is
|
|
63
|
+
* given, else the discount spread in proportion), shipping taxed on top. A line whose price includes the tax contributes to `includedMinor`
|
|
64
|
+
* (the tax inside the price), the others and the shipping to `addedMinor`. The rate and rounding are the money helpers', so it is also the
|
|
65
|
+
* small reference the conformance suite is written from.
|
|
66
|
+
*/
|
|
67
|
+
declare function createFlatTax(options?: FlatTaxOptions): TaxPort;
|
|
68
|
+
//#endregion
|
|
69
|
+
//#region src/testing/slots.d.ts
|
|
70
|
+
/** The places of each ref (`{ slot_a: 3 }`). A ref that is not here is not sold. */
|
|
71
|
+
type SlotPlaces = Readonly<Record<string, number>>;
|
|
72
|
+
interface MemorySlotsOptions {
|
|
73
|
+
/** The price of one place of every slot, in centavos. Default 10000 (100.00 MXN). */
|
|
74
|
+
unitAmountMinor?: number;
|
|
75
|
+
}
|
|
76
|
+
declare function createMemorySlots(places: SlotPlaces, options?: MemorySlotsOptions): HoldingLineSource;
|
|
77
|
+
//#endregion
|
|
78
|
+
//#region src/testing/line-hold-conformance.d.ts
|
|
79
|
+
interface LineHoldConformanceOptions {
|
|
80
|
+
/** The context for a given clock. Default {@link testContext}; a source that keeps its places in D1 passes one with the project's `env.DB`. */
|
|
81
|
+
context?: (now: number) => PortContext | Promise<PortContext>;
|
|
82
|
+
}
|
|
83
|
+
declare function runLineHoldConformance(make: (places: SlotPlaces) => LineSource | Promise<LineSource>, tools: TestTools, options?: LineHoldConformanceOptions): void;
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region src/testing/line-source.d.ts
|
|
86
|
+
interface KnownRef {
|
|
87
|
+
ref: string;
|
|
88
|
+
/** What the source must charge for one. Leave out to check only that it is a valid price. */
|
|
89
|
+
unitAmountMinor?: number;
|
|
90
|
+
currency?: string;
|
|
91
|
+
/** Options to resolve this ref with (a size, a colour), for a source that has them. */
|
|
92
|
+
options?: Readonly<Record<string, string>>;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A source whose price the buyer legitimately chooses inside bounds the server sets (a donation, a tip, a deposit of the buyer's size). The
|
|
96
|
+
* amount travels in `options[option]`, in minor units, as a string of digits. The harness then checks that the bounds hold and that nothing
|
|
97
|
+
* but a whole number inside them is ever priced; every other option name is still a price the buyer may not set.
|
|
98
|
+
*/
|
|
99
|
+
interface BuyerPrice {
|
|
100
|
+
/** A ref in `known` whose `options` carry an amount (`{ amount: "5000" }`): the ref the amount is chosen on. */
|
|
101
|
+
ref: string;
|
|
102
|
+
/** The option the buyer puts the amount in. */
|
|
103
|
+
option: string;
|
|
104
|
+
/** The least and the most the source accepts, in minor units. Each must be accepted, and one either side refused. */
|
|
105
|
+
min: number;
|
|
106
|
+
max: number;
|
|
107
|
+
}
|
|
108
|
+
interface LineSourceFixtures {
|
|
109
|
+
/** The context the source runs in. Default {@link testContext}: a source that reads D1 passes its own, with the project's `env.DB`. */
|
|
110
|
+
ctx?: PortContext;
|
|
111
|
+
/** Refs that exist and can be bought. At least one. */
|
|
112
|
+
known: readonly KnownRef[];
|
|
113
|
+
/** Refs that exist but cannot be bought, with the availability the source must give them. */
|
|
114
|
+
unavailable?: readonly {
|
|
115
|
+
ref: string;
|
|
116
|
+
availability: Exclude<LineAvailability, "available" | "not_found">;
|
|
117
|
+
}[];
|
|
118
|
+
/** A ref that exists nowhere. Default `no-such-thing-here`. */
|
|
119
|
+
missingRef?: string;
|
|
120
|
+
/** For a source that lets the buyer pick the amount inside bounds (`donation.amount`). */
|
|
121
|
+
buyerPrice?: BuyerPrice;
|
|
122
|
+
/** More option names to try as a hostile price, beyond {@link PRICE_OPTION_NAMES}: the ones your source reads (`pledge`, `tip`). */
|
|
123
|
+
extraPriceOptions?: readonly string[];
|
|
124
|
+
}
|
|
125
|
+
declare const HARNESS_CHECKS: readonly ["empty", "shape", "order", "unknown", "unavailable", "server-prices", "no-mutation", "repeatable", "duplicates", "buyer-price", "holds"];
|
|
126
|
+
type HarnessCheck = (typeof HARNESS_CHECKS)[number];
|
|
127
|
+
interface HarnessFinding {
|
|
128
|
+
check: HarnessCheck;
|
|
129
|
+
message: string;
|
|
130
|
+
problems?: readonly CommerceProblem[];
|
|
131
|
+
}
|
|
132
|
+
/** Refs a hostile buyer sends. A source answers each with a `not_found` line, whatever it does with a ref. */
|
|
133
|
+
declare const HOSTILE_REFS: readonly string[];
|
|
134
|
+
/**
|
|
135
|
+
* The option names the harness tries as a browser's own price. A source may not read a price from any of them (except the one `buyerPrice`
|
|
136
|
+
* declares); add the names your source reads for other things with `extraPriceOptions`.
|
|
137
|
+
*/
|
|
138
|
+
declare const PRICE_OPTION_NAMES: readonly ["price", "amount", "unitAmountMinor", "unit_price", "total", "currency", "discount"];
|
|
139
|
+
/** Runs the checks and returns what failed; an empty list is a source a checkout can rely on. */
|
|
140
|
+
declare function checkLineSource(source: LineSource, fixtures: LineSourceFixtures, only?: readonly HarnessCheck[]): Promise<HarnessFinding[]>;
|
|
141
|
+
/** The checks as a test suite: one `it` for each, failing with the findings. Run it in the feature's own tests, in workerd for a source that reads D1. */
|
|
142
|
+
declare function runLineSourceConformance(source: LineSource, fixtures: LineSourceFixtures, tools: TestTools): void;
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/testing/payments.d.ts
|
|
145
|
+
type Failure = Extract<StartPaymentResult, {
|
|
146
|
+
ok: false;
|
|
147
|
+
}>;
|
|
148
|
+
interface FakePayments extends PaymentsPort {
|
|
149
|
+
/** Every distinct request that was accepted, in order. */
|
|
150
|
+
readonly started: readonly StartPaymentInput[];
|
|
151
|
+
/** Every reference whose payment was cancelled, in order, once each. */
|
|
152
|
+
readonly cancelled: readonly string[];
|
|
153
|
+
/** The next `start` answers with this failure, once. */
|
|
154
|
+
failNext(failure: Failure): void;
|
|
155
|
+
/** The payment of `reference` was paid, as a provider's event would say: it can no longer be cancelled or started again. */
|
|
156
|
+
markPaid(reference: string): void;
|
|
157
|
+
cancel: NonNullable<PaymentsPort["cancel"]>;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* A payments port for tests: it checks each request the way a real one must (`checkStartPayment`), answers a payment id and a redirect URL,
|
|
161
|
+
* and is idempotent by `reference` (the same request again is the same payment; a different one under the same reference is refused). It
|
|
162
|
+
* cancels a payment that is not paid, once, and then refuses to start its reference again; `markPaid` stands in for the provider's event.
|
|
163
|
+
*/
|
|
164
|
+
declare function createFakePayments(): FakePayments;
|
|
165
|
+
//#endregion
|
|
166
|
+
//#region src/testing/payments-conformance.d.ts
|
|
167
|
+
interface PaymentsConformanceOptions {
|
|
168
|
+
/** The context for a given clock. Default {@link testContext}; a provider that keeps payments in D1 passes one with the project's `env.DB`. */
|
|
169
|
+
context?: (now: number) => PortContext | Promise<PortContext>;
|
|
170
|
+
/**
|
|
171
|
+
* Leaves out the cases of `cancel`, for a provider that does not have that optional method. Without it, a provider with no `cancel` fails
|
|
172
|
+
* those cases and says why.
|
|
173
|
+
*/
|
|
174
|
+
skipCancel?: boolean;
|
|
175
|
+
/** The start of every reference the suite makes (`<prefix>-<n>`), for a provider whose payments outlive one test. Default `conformance`. */
|
|
176
|
+
referencePrefix?: string;
|
|
177
|
+
}
|
|
178
|
+
declare function runPaymentsConformance(make: () => PaymentsPort | Promise<PaymentsPort>, tools: TestTools, options?: PaymentsConformanceOptions): void;
|
|
179
|
+
//#endregion
|
|
180
|
+
//#region src/testing/stock.d.ts
|
|
181
|
+
/** How one subject is stocked. A subject that is not seeded is `none`: always available. */
|
|
182
|
+
type StockSeedItem = {
|
|
183
|
+
tracking: "none";
|
|
184
|
+
} | {
|
|
185
|
+
tracking: "toggle";
|
|
186
|
+
out?: boolean;
|
|
187
|
+
outUntil?: number | null;
|
|
188
|
+
} | {
|
|
189
|
+
tracking: "count";
|
|
190
|
+
onHand: number;
|
|
191
|
+
allowOversell?: boolean;
|
|
192
|
+
};
|
|
193
|
+
/** Seeds by `subjectKey` (`catalog.variant:var_1`). */
|
|
194
|
+
type StockSeed = Readonly<Record<string, StockSeedItem>>;
|
|
195
|
+
declare const subjectKey: (subject: StockSubject) => string;
|
|
196
|
+
interface MemoryStock extends StockPort {
|
|
197
|
+
fulfil: NonNullable<StockPort["fulfil"]>;
|
|
198
|
+
uncommit: NonNullable<StockPort["uncommit"]>;
|
|
199
|
+
/** The counts of every `count` subject: for a test to read what the port did. */
|
|
200
|
+
counts(): Record<string, {
|
|
201
|
+
onHand: number;
|
|
202
|
+
reserved: number;
|
|
203
|
+
committed: number;
|
|
204
|
+
available: number;
|
|
205
|
+
}>;
|
|
206
|
+
}
|
|
207
|
+
declare function createMemoryStock(seed?: StockSeed): MemoryStock;
|
|
208
|
+
//#endregion
|
|
209
|
+
//#region src/testing/stock-conformance.d.ts
|
|
210
|
+
interface StockConformanceOptions {
|
|
211
|
+
/** The context for a given clock. Default {@link testContext}; a D1 provider passes one with the project's `env.DB`. */
|
|
212
|
+
context?: (now: number) => PortContext | Promise<PortContext>;
|
|
213
|
+
/**
|
|
214
|
+
* Leaves out the cases of `fulfil`, for a provider that does not have that optional method (it tracks no units on hand). Without it, a
|
|
215
|
+
* provider with no `fulfil` fails those cases and says why.
|
|
216
|
+
*/
|
|
217
|
+
skipFulfil?: boolean;
|
|
218
|
+
/**
|
|
219
|
+
* Leaves out the cases of `uncommit`, for a provider that does not have that optional method. Without it, a provider with no `uncommit`
|
|
220
|
+
* fails those cases and says why.
|
|
221
|
+
*/
|
|
222
|
+
skipUncommit?: boolean;
|
|
223
|
+
}
|
|
224
|
+
declare function runStockConformance(make: (seed: StockSeed) => StockPort | Promise<StockPort>, tools: TestTools, options?: StockConformanceOptions): void;
|
|
225
|
+
//#endregion
|
|
226
|
+
//#region src/testing/tax-conformance.d.ts
|
|
227
|
+
interface TaxConformanceOptions {
|
|
228
|
+
/** The context for a given clock. Default {@link testContext}; a provider that reads D1 passes one with the project's `env.DB`. */
|
|
229
|
+
context?: (now: number) => PortContext | Promise<PortContext>;
|
|
230
|
+
/** A destination the provider can price; default none. The suite asks with and without. */
|
|
231
|
+
destination?: Destination;
|
|
232
|
+
/** The order's currency. Default `MXN`. */
|
|
233
|
+
currency?: string;
|
|
234
|
+
}
|
|
235
|
+
declare function runTaxConformance(make: () => TaxPort | Promise<TaxPort>, tools: TestTools, options?: TaxConformanceOptions): void;
|
|
236
|
+
//#endregion
|
|
237
|
+
export { type DiscountConformanceOptions, type FakePayments, type FlatDiscountOptions, type FlatTaxOptions, HARNESS_CHECKS, HOSTILE_REFS, type HarnessCheck, type HarnessFinding, type KnownRef, type LineHoldConformanceOptions, type LineSourceFixtures, type MemorySlotsOptions, type MemoryStock, PRICE_OPTION_NAMES, type PaymentsConformanceOptions, type SlotPlaces, type StockConformanceOptions, type StockSeed, type StockSeedItem, TEST_NOW, type TaxConformanceOptions, type TestExpect, type TestTools, checkLineSource, createFakePayments, createFlatDiscount, createFlatTax, createMemorySlots, createMemoryStock, runDiscountConformance, runLineHoldConformance, runLineSourceConformance, runPaymentsConformance, runStockConformance, runTaxConformance, subjectKey, testContext };
|