@tokenoftrust/storefront-runner 3.7.2 → 3.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +51 -7
- package/packages/public-runtime/src/checkout.ts +45 -14
- package/packages/public-runtime/src/declared-tenant-config.d.mts +3 -0
- package/packages/public-runtime/src/declared-tenant-config.mjs +64 -8
- package/packages/public-runtime/src/tenant.ts +19 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.8.0",
|
|
4
4
|
"license": "SEE LICENSE IN LICENSE",
|
|
5
5
|
"description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
|
|
6
6
|
"packageManager": "pnpm@11.9.0",
|
|
@@ -197,20 +197,38 @@ export function seoDeclarationProblems(seo) {
|
|
|
197
197
|
}
|
|
198
198
|
|
|
199
199
|
/** Every `shipping` key (mirrors packages/public-runtime/src/declared-tenant-config.mjs). */
|
|
200
|
-
export const SHIPPING_KEYS = Object.freeze(["
|
|
200
|
+
export const SHIPPING_KEYS = Object.freeze(["tiers"]);
|
|
201
|
+
|
|
202
|
+
/** Every key of one `shipping.tiers` entry. */
|
|
203
|
+
export const SHIPPING_TIER_KEYS = Object.freeze(["atOrAbove", "rate", "label"]);
|
|
201
204
|
|
|
202
205
|
/** Largest shipping amount a declaration may state, in the cart's currency. */
|
|
203
206
|
export const MAX_SHIPPING_AMOUNT = 100000;
|
|
204
207
|
|
|
208
|
+
/** Most tiers one policy may declare. */
|
|
209
|
+
export const MAX_SHIPPING_TIERS = 10;
|
|
210
|
+
|
|
211
|
+
/** Longest shipping-line label, in characters. */
|
|
212
|
+
export const MAX_SHIPPING_LABEL_LENGTH = 100;
|
|
213
|
+
|
|
205
214
|
/** @param {unknown} value */
|
|
206
215
|
const isShippingAmount = (value) =>
|
|
207
216
|
typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_SHIPPING_AMOUNT
|
|
208
217
|
&& Math.abs(value * 100 - Math.round(value * 100)) < 1e-6;
|
|
209
218
|
|
|
219
|
+
/** @param {unknown} value */
|
|
220
|
+
const isShippingLabel = (value) =>
|
|
221
|
+
typeof value === "string" && value.trim() === value && value.length > 0
|
|
222
|
+
&& [...value].length <= MAX_SHIPPING_LABEL_LENGTH && !/[\u0000-\u001f\u007f]/.test(value);
|
|
223
|
+
|
|
210
224
|
/**
|
|
211
225
|
* What is wrong with a declared `shipping` value, one sentence per problem; `[]` when it is
|
|
212
226
|
* valid. A policy with any problem is refused whole, so checkout never charges half of one.
|
|
213
227
|
*
|
|
228
|
+
* Tiers are ordered by the item subtotal they start at. The first starts at 0 and states no
|
|
229
|
+
* `atOrAbove`; each later tier's `atOrAbove` is above the one before it and is inclusive, so an
|
|
230
|
+
* order whose subtotal equals it pays that tier's `rate`.
|
|
231
|
+
*
|
|
214
232
|
* @param {unknown} shipping
|
|
215
233
|
* @returns {string[]}
|
|
216
234
|
*/
|
|
@@ -221,12 +239,38 @@ export function shippingDeclarationProblems(shipping) {
|
|
|
221
239
|
for (const key of Object.keys(source)) {
|
|
222
240
|
if (!SHIPPING_KEYS.includes(key)) problems.push(`\`shipping.${key}\` is not a shipping key (${SHIPPING_KEYS.join(", ")})`);
|
|
223
241
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
problems.push(`\`shipping.freeAtOrAbove\` must be an amount above 0 and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
|
|
242
|
+
const tiers = source.tiers;
|
|
243
|
+
if (!Array.isArray(tiers) || tiers.length === 0 || tiers.length > MAX_SHIPPING_TIERS) {
|
|
244
|
+
problems.push(`\`shipping.tiers\` must be a list of 1 to ${MAX_SHIPPING_TIERS} tiers`);
|
|
245
|
+
return problems;
|
|
229
246
|
}
|
|
247
|
+
let previous = 0;
|
|
248
|
+
tiers.forEach((tier, index) => {
|
|
249
|
+
const at = `shipping.tiers[${index}]`;
|
|
250
|
+
if (!isPlainObject(tier)) {
|
|
251
|
+
problems.push(`\`${at}\` must be an object`);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const entry = /** @type {Record<string, unknown>} */ (tier);
|
|
255
|
+
for (const key of Object.keys(entry)) {
|
|
256
|
+
if (!SHIPPING_TIER_KEYS.includes(key)) problems.push(`\`${at}.${key}\` is not a tier key (${SHIPPING_TIER_KEYS.join(", ")})`);
|
|
257
|
+
}
|
|
258
|
+
if (!isShippingAmount(entry.rate)) {
|
|
259
|
+
problems.push(`\`${at}.rate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
|
|
260
|
+
}
|
|
261
|
+
if (entry.label !== undefined && !isShippingLabel(entry.label)) {
|
|
262
|
+
problems.push(`\`${at}.label\` must be text of 1 to ${MAX_SHIPPING_LABEL_LENGTH} characters with no surrounding spaces or line breaks`);
|
|
263
|
+
}
|
|
264
|
+
if (index === 0) {
|
|
265
|
+
if (entry.atOrAbove !== undefined) problems.push(`\`${at}\` starts at 0, so it states no \`atOrAbove\``);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (!(isShippingAmount(entry.atOrAbove) && /** @type {number} */ (entry.atOrAbove) > previous)) {
|
|
269
|
+
problems.push(`\`${at}.atOrAbove\` must be an amount above ${previous}, where the tier before it starts, and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
previous = /** @type {number} */ (entry.atOrAbove);
|
|
273
|
+
});
|
|
230
274
|
return problems;
|
|
231
275
|
}
|
|
232
276
|
|
|
@@ -702,7 +746,7 @@ export function validateDeclaredConfig(config, file = ".tot/config.json") {
|
|
|
702
746
|
const problems = shippingDeclarationProblems(config.shipping);
|
|
703
747
|
if (problems.length > 0) {
|
|
704
748
|
out.push(mk(ERROR, "config-shipping", `${file} shipping`, shippingProblemMessage(problems),
|
|
705
|
-
'declare e.g. `"shipping": { "
|
|
749
|
+
'declare e.g. `"shipping": { "tiers": [ { "rate": 5.99 }, { "atOrAbove": 100, "rate": 0, "label": "Free shipping" } ] }`, or remove `shipping` to charge none'));
|
|
706
750
|
}
|
|
707
751
|
}
|
|
708
752
|
return out;
|
|
@@ -224,12 +224,19 @@ export class ShippingPolicyUnavailableError extends Error {
|
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
/**
|
|
227
|
+
/** One shipping tier in the minor units of one currency. */
|
|
228
|
+
export interface ShippingTierMinor {
|
|
229
|
+
/** Item subtotal the tier starts at, inclusive; 0 for the first tier. */
|
|
230
|
+
fromMinor: number;
|
|
231
|
+
rateMinor: number;
|
|
232
|
+
label: string | null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** A shipping policy in the minor units of one currency, tiers ascending by `fromMinor`. */
|
|
228
236
|
export interface ShippingPolicyMinor {
|
|
229
237
|
currency: string;
|
|
230
238
|
digits: number;
|
|
231
|
-
|
|
232
|
-
freeAtOrAboveMinor: number | null;
|
|
239
|
+
tiers: ShippingTierMinor[];
|
|
233
240
|
}
|
|
234
241
|
|
|
235
242
|
function minorUnits(amount: number, digits: number): number {
|
|
@@ -255,27 +262,51 @@ export function shippingPolicyMinor(policy: ShippingPolicy, currency: string): S
|
|
|
255
262
|
throw new ShippingPolicyUnavailableError();
|
|
256
263
|
}
|
|
257
264
|
if (digits === undefined) throw new ShippingPolicyUnavailableError();
|
|
258
|
-
const
|
|
259
|
-
if (
|
|
260
|
-
|
|
265
|
+
const declared = Array.isArray(policy?.tiers) ? policy.tiers : [];
|
|
266
|
+
if (declared.length === 0) throw new ShippingPolicyUnavailableError();
|
|
267
|
+
const tiers = declared.map((tier, index): ShippingTierMinor => {
|
|
268
|
+
if ((index === 0) !== (tier.atOrAbove === undefined)) throw new ShippingPolicyUnavailableError();
|
|
269
|
+
return {
|
|
270
|
+
fromMinor: index === 0 ? 0 : minorUnits(tier.atOrAbove!, digits),
|
|
271
|
+
rateMinor: minorUnits(tier.rate, digits),
|
|
272
|
+
label: tier.label ?? null,
|
|
273
|
+
};
|
|
274
|
+
});
|
|
275
|
+
// Tiers that collapse onto one minor-unit start (or run backwards) cannot say which applies.
|
|
276
|
+
if (tiers.some((tier, index) => index > 0 && tier.fromMinor <= tiers[index - 1]!.fromMinor)) {
|
|
277
|
+
throw new ShippingPolicyUnavailableError();
|
|
278
|
+
}
|
|
279
|
+
return { currency, digits, tiers };
|
|
261
280
|
}
|
|
262
281
|
|
|
263
|
-
/**
|
|
282
|
+
/**
|
|
283
|
+
* The tier an order whose item subtotal is `subtotalMinor` pays: the last one whose start it
|
|
284
|
+
* reaches. A subtotal exactly at a tier's `atOrAbove` pays that tier.
|
|
285
|
+
*/
|
|
286
|
+
export function shippingTierFor(policy: ShippingPolicy, currency: string, subtotalMinor: number): ShippingTierMinor {
|
|
287
|
+
const { tiers } = shippingPolicyMinor(policy, currency);
|
|
288
|
+
return tiers.reduce((paid, tier) => (subtotalMinor >= tier.fromMinor ? tier : paid), tiers[0]!);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Shipping owed, in minor units, on an order whose item subtotal is `subtotalMinor`. */
|
|
264
292
|
export function shippingMinorFor(policy: ShippingPolicy, currency: string, subtotalMinor: number): number {
|
|
265
|
-
|
|
266
|
-
return minor.freeAtOrAboveMinor !== null && subtotalMinor >= minor.freeAtOrAboveMinor ? 0 : minor.flatMinor;
|
|
293
|
+
return shippingTierFor(policy, currency, subtotalMinor).rateMinor;
|
|
267
294
|
}
|
|
268
295
|
|
|
269
296
|
/**
|
|
270
|
-
* The `shipping_policy` option value
|
|
271
|
-
*
|
|
272
|
-
*
|
|
297
|
+
* The `shipping_policy` option value: `currency=<code>` then one `tier=<from>,<rate>[,<label>]`
|
|
298
|
+
* per tier, ascending, e.g.
|
|
299
|
+
* `currency=USD;tier=0.00,14.99,Flat%20rate%20shipping;tier=100.00,7.00,FREE%20shipping`.
|
|
300
|
+
* Amounts carry exactly the currency's minor-unit digits; a label is `encodeURIComponent`-encoded
|
|
301
|
+
* so it cannot contain `,` or `;`. The checkout store's shipping code parses this shape and
|
|
302
|
+
* refuses any other.
|
|
273
303
|
*/
|
|
274
304
|
export function shippingPolicyOptionValue(policy: ShippingPolicy, currency: string): string {
|
|
275
305
|
const minor = shippingPolicyMinor(policy, currency);
|
|
276
306
|
const amount = (value: number) => (value / 10 ** minor.digits).toFixed(minor.digits);
|
|
277
|
-
return [`currency=${currency}`,
|
|
278
|
-
...(
|
|
307
|
+
return [`currency=${currency}`, ...minor.tiers.map((tier) =>
|
|
308
|
+
`tier=${[amount(tier.fromMinor), amount(tier.rateMinor), ...(tier.label === null ? [] : [encodeURIComponent(tier.label)])].join(",")}`)]
|
|
309
|
+
.join(";");
|
|
279
310
|
}
|
|
280
311
|
|
|
281
312
|
/** A verification correlation handle as a cart may carry it (the commerce adapter's shape). */
|
|
@@ -21,7 +21,10 @@ export const STRUCTURED_DATA_NAMED_TYPES: readonly string[];
|
|
|
21
21
|
export const STRUCTURED_DATA_NAME_SOURCES: readonly string[];
|
|
22
22
|
export function seoDeclarationProblems(seo: unknown): string[];
|
|
23
23
|
export const SHIPPING_KEYS: readonly string[];
|
|
24
|
+
export const SHIPPING_TIER_KEYS: readonly string[];
|
|
24
25
|
export const MAX_SHIPPING_AMOUNT: number;
|
|
26
|
+
export const MAX_SHIPPING_TIERS: number;
|
|
27
|
+
export const MAX_SHIPPING_LABEL_LENGTH: number;
|
|
25
28
|
export function shippingDeclarationProblems(shipping: unknown): string[];
|
|
26
29
|
export function declaredTenantConfig(config: unknown): DeclaredTenantConfig;
|
|
27
30
|
export function isEmptyDeclaredTenantConfig(declared: DeclaredTenantConfig | null | undefined): boolean;
|
|
@@ -243,20 +243,38 @@ export function seoDeclarationProblems(seo) {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
/** Every `shipping` key (ShippingPolicy, tenant.ts). */
|
|
246
|
-
export const SHIPPING_KEYS = Object.freeze(["
|
|
246
|
+
export const SHIPPING_KEYS = Object.freeze(["tiers"]);
|
|
247
|
+
|
|
248
|
+
/** Every key of one `shipping.tiers` entry (ShippingTier, tenant.ts). */
|
|
249
|
+
export const SHIPPING_TIER_KEYS = Object.freeze(["atOrAbove", "rate", "label"]);
|
|
247
250
|
|
|
248
251
|
/** Largest shipping amount a declaration may state, in the cart's currency. */
|
|
249
252
|
export const MAX_SHIPPING_AMOUNT = 100000;
|
|
250
253
|
|
|
254
|
+
/** Most tiers one policy may declare. */
|
|
255
|
+
export const MAX_SHIPPING_TIERS = 10;
|
|
256
|
+
|
|
257
|
+
/** Longest shipping-line label, in characters. */
|
|
258
|
+
export const MAX_SHIPPING_LABEL_LENGTH = 100;
|
|
259
|
+
|
|
251
260
|
/** @param {unknown} value */
|
|
252
261
|
const isShippingAmount = (value) =>
|
|
253
262
|
typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= MAX_SHIPPING_AMOUNT
|
|
254
263
|
&& Math.abs(value * 100 - Math.round(value * 100)) < 1e-6;
|
|
255
264
|
|
|
265
|
+
/** @param {unknown} value */
|
|
266
|
+
const isShippingLabel = (value) =>
|
|
267
|
+
typeof value === "string" && value.trim() === value && value.length > 0
|
|
268
|
+
&& [...value].length <= MAX_SHIPPING_LABEL_LENGTH && !/[\u0000-\u001f\u007f]/.test(value);
|
|
269
|
+
|
|
256
270
|
/**
|
|
257
271
|
* What is wrong with a declared `shipping` value, one sentence per problem; `[]` when it is
|
|
258
272
|
* valid. A policy with any problem is refused whole, so checkout never charges half of one.
|
|
259
273
|
*
|
|
274
|
+
* Tiers are ordered by the item subtotal they start at. The first starts at 0 and states no
|
|
275
|
+
* `atOrAbove`; each later tier's `atOrAbove` is above the one before it and is inclusive, so an
|
|
276
|
+
* order whose subtotal equals it pays that tier's `rate`.
|
|
277
|
+
*
|
|
260
278
|
* @param {unknown} shipping
|
|
261
279
|
* @returns {string[]}
|
|
262
280
|
*/
|
|
@@ -267,15 +285,54 @@ export function shippingDeclarationProblems(shipping) {
|
|
|
267
285
|
for (const key of Object.keys(source)) {
|
|
268
286
|
if (!SHIPPING_KEYS.includes(key)) problems.push(`\`shipping.${key}\` is not a shipping key (${SHIPPING_KEYS.join(", ")})`);
|
|
269
287
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
problems.push(`\`shipping.freeAtOrAbove\` must be an amount above 0 and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
|
|
288
|
+
const tiers = source.tiers;
|
|
289
|
+
if (!Array.isArray(tiers) || tiers.length === 0 || tiers.length > MAX_SHIPPING_TIERS) {
|
|
290
|
+
problems.push(`\`shipping.tiers\` must be a list of 1 to ${MAX_SHIPPING_TIERS} tiers`);
|
|
291
|
+
return problems;
|
|
275
292
|
}
|
|
293
|
+
let previous = 0;
|
|
294
|
+
tiers.forEach((tier, index) => {
|
|
295
|
+
const at = `shipping.tiers[${index}]`;
|
|
296
|
+
if (!isPlainObject(tier)) {
|
|
297
|
+
problems.push(`\`${at}\` must be an object`);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const entry = /** @type {Record<string, unknown>} */ (tier);
|
|
301
|
+
for (const key of Object.keys(entry)) {
|
|
302
|
+
if (!SHIPPING_TIER_KEYS.includes(key)) problems.push(`\`${at}.${key}\` is not a tier key (${SHIPPING_TIER_KEYS.join(", ")})`);
|
|
303
|
+
}
|
|
304
|
+
if (!isShippingAmount(entry.rate)) {
|
|
305
|
+
problems.push(`\`${at}.rate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
|
|
306
|
+
}
|
|
307
|
+
if (entry.label !== undefined && !isShippingLabel(entry.label)) {
|
|
308
|
+
problems.push(`\`${at}.label\` must be text of 1 to ${MAX_SHIPPING_LABEL_LENGTH} characters with no surrounding spaces or line breaks`);
|
|
309
|
+
}
|
|
310
|
+
if (index === 0) {
|
|
311
|
+
if (entry.atOrAbove !== undefined) problems.push(`\`${at}\` starts at 0, so it states no \`atOrAbove\``);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
if (!(isShippingAmount(entry.atOrAbove) && /** @type {number} */ (entry.atOrAbove) > previous)) {
|
|
315
|
+
problems.push(`\`${at}.atOrAbove\` must be an amount above ${previous}, where the tier before it starts, and at most ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
previous = /** @type {number} */ (entry.atOrAbove);
|
|
319
|
+
});
|
|
276
320
|
return problems;
|
|
277
321
|
}
|
|
278
322
|
|
|
323
|
+
/**
|
|
324
|
+
* A valid declaration copied to exactly its declared keys.
|
|
325
|
+
*
|
|
326
|
+
* @param {{ tiers: Array<Record<string, unknown>> }} policy
|
|
327
|
+
*/
|
|
328
|
+
const shippingPolicyOf = (policy) => ({
|
|
329
|
+
tiers: policy.tiers.map((tier) => ({
|
|
330
|
+
...(tier.atOrAbove === undefined ? {} : { atOrAbove: tier.atOrAbove }),
|
|
331
|
+
rate: tier.rate,
|
|
332
|
+
...(tier.label === undefined ? {} : { label: tier.label }),
|
|
333
|
+
})),
|
|
334
|
+
});
|
|
335
|
+
|
|
279
336
|
/**
|
|
280
337
|
* The declared TenantConfig fields from a parsed `.tot/config.json`. A field whose
|
|
281
338
|
* value has the wrong shape is omitted, never coerced (`tot validate` reports it).
|
|
@@ -302,8 +359,7 @@ export function declaredTenantConfig(config) {
|
|
|
302
359
|
// The shipping charge: refused whole when any part is malformed (`tot validate` and the
|
|
303
360
|
// reconcile report it), so checkout keeps charging no shipping rather than a guess.
|
|
304
361
|
if (shipping !== undefined && shippingDeclarationProblems(shipping).length === 0) {
|
|
305
|
-
|
|
306
|
-
out.shipping = { flatRate: policy.flatRate, ...(policy.freeAtOrAbove === undefined ? {} : { freeAtOrAbove: policy.freeAtOrAbove }) };
|
|
362
|
+
out.shipping = shippingPolicyOf(/** @type {{ tiers: Array<Record<string, unknown>> }} */ (shipping));
|
|
307
363
|
}
|
|
308
364
|
return out;
|
|
309
365
|
}
|
|
@@ -773,16 +773,27 @@ export interface TenantConfig {
|
|
|
773
773
|
}
|
|
774
774
|
|
|
775
775
|
/**
|
|
776
|
-
* A store's shipping charge: one
|
|
777
|
-
*
|
|
778
|
-
*
|
|
779
|
-
* declaration (checkout.ts `shippingMinorFor`).
|
|
776
|
+
* A store's shipping charge: one rate per order, chosen by the order's item subtotal from
|
|
777
|
+
* `tiers`. The cart carries it as the signed `shipping_policy` option; the checkout store
|
|
778
|
+
* charges it under the tier's label, and the final-cart authority re-derives the amount from
|
|
779
|
+
* this declaration (checkout.ts `shippingMinorFor`).
|
|
780
780
|
*/
|
|
781
781
|
export interface ShippingPolicy {
|
|
782
|
-
/**
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
782
|
+
/**
|
|
783
|
+
* Ascending by `atOrAbove`. The first tier starts at 0 and states no `atOrAbove`; an order
|
|
784
|
+
* pays the last tier whose start its item subtotal reaches.
|
|
785
|
+
*/
|
|
786
|
+
tiers: ShippingTier[];
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** One band of item subtotals and what an order in it pays for shipping. */
|
|
790
|
+
export interface ShippingTier {
|
|
791
|
+
/** Item subtotal this tier starts at, inclusive, in the cart's currency. Absent on the first tier. */
|
|
792
|
+
atOrAbove?: number;
|
|
793
|
+
/** Charged once per order, in the cart's currency. `0` ships free. */
|
|
794
|
+
rate: number;
|
|
795
|
+
/** The shipping line the shopper reads at checkout. Absent ⇒ "Free shipping" or "Shipping". */
|
|
796
|
+
label?: string;
|
|
786
797
|
}
|
|
787
798
|
|
|
788
799
|
/** Page types a structured-data profile declares a graph for (STRUCTURED_DATA_PAGE_TYPES). */
|