@tokenoftrust/storefront-runner 3.7.2 → 3.9.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 +95 -25
- 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.9.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). */
|
|
@@ -298,16 +329,46 @@ export const VISIBLE_CART_FIELDS = ["Purchase option"] as const;
|
|
|
298
329
|
|
|
299
330
|
export type CartFieldVisibility = "native" | "internal" | "visible" | "variant-option" | "undeclared";
|
|
300
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Characters the checkout store does not keep in a field name, each mapped to a look-alike it
|
|
334
|
+
* keeps. The store validates the name as it rewrites it, not as it was signed: ASCII space, `.`
|
|
335
|
+
* and `[` become `_` (a `[…]` pair turns the field into an array), `||` ends the name, and a name
|
|
336
|
+
* holding `&` or `"` is dropped from validation. Measured 2026-10-23 against tot-preview.foxycart.com
|
|
337
|
+
* by submitting each name with a non-matching hash and reading the name back from its
|
|
338
|
+
* "Cart Validation Error: <name>" refusal. Every other character (NBSP, `/`, `'`, `+`, `-`,
|
|
339
|
+
* non-ASCII) came back unchanged.
|
|
340
|
+
*/
|
|
341
|
+
const CHECKOUT_NAME_LOOKALIKES: Readonly<Record<string, string>> = {
|
|
342
|
+
" ": " ",
|
|
343
|
+
".": "․",
|
|
344
|
+
"[": "(",
|
|
345
|
+
"]": ")",
|
|
346
|
+
"&": "&",
|
|
347
|
+
'"': "”",
|
|
348
|
+
"|": "¦",
|
|
349
|
+
};
|
|
350
|
+
const CHECKOUT_NAME_REWRITTEN = /[ .[\]&"|]/g;
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* The name a cart field is emitted AND signed under. The checkout store validates this exact
|
|
354
|
+
* string, and the shopper's cart line shows it, so "Nic Strength" stays readable as
|
|
355
|
+
* "Nic Strength" (with a no-break space) instead of failing as `Nic_Strength`.
|
|
356
|
+
*/
|
|
357
|
+
export function checkoutFieldName(name: string): string {
|
|
358
|
+
return name.trim().replace(CHECKOUT_NAME_REWRITTEN, (ch) => CHECKOUT_NAME_LOOKALIKES[ch] ?? ch);
|
|
359
|
+
}
|
|
360
|
+
|
|
301
361
|
/**
|
|
302
362
|
* How a field emitted by `cartFieldsForVariant` reaches the shopper. A field
|
|
303
363
|
* that is none of native, internal, declared-visible, or one of the variant's
|
|
304
364
|
* own options is `undeclared`: it would render on the cart line unreviewed.
|
|
365
|
+
* `name` is the emitted name (`checkoutFieldName`).
|
|
305
366
|
*/
|
|
306
367
|
export function cartFieldVisibility(name: string, variant: Pick<CatalogVariant, "option_values">): CartFieldVisibility {
|
|
307
368
|
if ((NATIVE_CART_FIELDS as readonly string[]).includes(name)) return "native";
|
|
308
369
|
if ((INTERNAL_CART_FIELDS as readonly string[]).includes(name)) return "internal";
|
|
309
|
-
if ((
|
|
310
|
-
if (Object.
|
|
370
|
+
if (VISIBLE_CART_FIELDS.some((visible) => checkoutFieldName(visible) === name)) return "visible";
|
|
371
|
+
if (Object.keys(variant.option_values).some((option) => checkoutFieldName(option) === name)) return "variant-option";
|
|
311
372
|
return "undeclared";
|
|
312
373
|
}
|
|
313
374
|
|
|
@@ -445,7 +506,9 @@ export function cartFieldsForVariant(
|
|
|
445
506
|
// Variant options (Flavor, Nicotine strength, …) travel as product options so
|
|
446
507
|
// the cart line reflects the exact variant the shopper picked.
|
|
447
508
|
for (const [optName, optVal] of Object.entries(variant.option_values)) {
|
|
448
|
-
|
|
509
|
+
const name = checkoutFieldName(optName);
|
|
510
|
+
const value = optVal.trim();
|
|
511
|
+
if (name && value) fields.push({ name, value });
|
|
449
512
|
}
|
|
450
513
|
// Bind the SELECTED purchase option into the signed payload so a shopper can't
|
|
451
514
|
// switch one-time↔subscription (or between plans) after the form is signed —
|
|
@@ -455,7 +518,7 @@ export function cartFieldsForVariant(
|
|
|
455
518
|
// on every non-subscription cart. Any authored option (a real one-time plan or
|
|
456
519
|
// a subscription) is emitted and signed → modifying it fails validation.
|
|
457
520
|
if (purchaseOption && !isDefaultOneTimeOption(purchaseOption)) {
|
|
458
|
-
fields.push({ name: "Purchase option", value: purchaseOption.label });
|
|
521
|
+
fields.push({ name: checkoutFieldName("Purchase option"), value: purchaseOption.label });
|
|
459
522
|
}
|
|
460
523
|
if (purchaseOption?.kind === "subscription") {
|
|
461
524
|
fields.push(...subscriptionCartFields(purchaseOption));
|
|
@@ -586,8 +649,14 @@ function constantTimeHexEqual(expected: string, presented: string): boolean {
|
|
|
586
649
|
return acc === 0;
|
|
587
650
|
}
|
|
588
651
|
|
|
652
|
+
/** A field as the checkout receives it unsigned: under its `checkoutFieldName`. */
|
|
653
|
+
function unsignedField(field: CartField): RenderedCartField {
|
|
654
|
+
return { name: checkoutFieldName(field.name), value: field.value };
|
|
655
|
+
}
|
|
656
|
+
|
|
589
657
|
/**
|
|
590
|
-
* Sign one field for product `code`.
|
|
658
|
+
* Sign one field for product `code`. The hashed name and the emitted name are the
|
|
659
|
+
* same `checkoutFieldName`, so they cannot diverge. Editable fields hash the sentinel
|
|
591
660
|
* `--OPEN--` and gain the `||open` suffix so checkout accepts a user-entered value.
|
|
592
661
|
*/
|
|
593
662
|
async function signField(
|
|
@@ -595,11 +664,12 @@ async function signField(
|
|
|
595
664
|
code: string,
|
|
596
665
|
field: CartField,
|
|
597
666
|
): Promise<RenderedCartField> {
|
|
667
|
+
const fieldName = checkoutFieldName(field.name);
|
|
598
668
|
const valueToHash = field.open ? "--OPEN--" : field.value;
|
|
599
|
-
const hash = await hmacHex(secret, code +
|
|
669
|
+
const hash = await hmacHex(secret, code + fieldName + valueToHash);
|
|
600
670
|
const name = field.open
|
|
601
|
-
? `${
|
|
602
|
-
: `${
|
|
671
|
+
? `${fieldName}||${hash}||open`
|
|
672
|
+
: `${fieldName}||${hash}`;
|
|
603
673
|
return { name, value: field.value };
|
|
604
674
|
}
|
|
605
675
|
|
|
@@ -642,7 +712,7 @@ export async function buildVariantCartPayload(
|
|
|
642
712
|
|
|
643
713
|
const rendered: RenderedCartField[] = signed
|
|
644
714
|
? await Promise.all(fields.map((f) => signField(opts.secret!, code, f)))
|
|
645
|
-
: fields.map(
|
|
715
|
+
: fields.map(unsignedField);
|
|
646
716
|
|
|
647
717
|
// Split the quantity field out: the island puts it on the visible stepper.
|
|
648
718
|
const qtyIdx = fields.findIndex((f) => f.name === "quantity");
|
|
@@ -830,7 +900,7 @@ export async function withVerificationRef(
|
|
|
830
900
|
const code = fields.find((field) => field.name.split("||")[0] === "code")?.value;
|
|
831
901
|
if (!code) return [...fields];
|
|
832
902
|
const field: CartField = { name: "verification", value: opts.ref };
|
|
833
|
-
if (opts.mode !== "signed") return [...fields, field];
|
|
903
|
+
if (opts.mode !== "signed") return [...fields, unsignedField(field)];
|
|
834
904
|
if (!opts.secret) throw new CartSigningUnavailableError();
|
|
835
905
|
return [...fields, await signField(opts.secret, code, field)];
|
|
836
906
|
}
|
|
@@ -852,7 +922,7 @@ export async function withShippingPolicy(
|
|
|
852
922
|
const currency = kept.find((field) => field.name.split("||")[0] === "currency")?.value;
|
|
853
923
|
if (!code || !currency) throw new ShippingPolicyUnavailableError();
|
|
854
924
|
const field: CartField = { name: SHIPPING_POLICY_CART_FIELD, value: shippingPolicyOptionValue(opts.policy, currency) };
|
|
855
|
-
if (opts.mode !== "signed") return [...kept, field];
|
|
925
|
+
if (opts.mode !== "signed") return [...kept, unsignedField(field)];
|
|
856
926
|
if (!opts.secret) throw new CartSigningUnavailableError();
|
|
857
927
|
return [...kept, await signField(opts.secret, code, field)];
|
|
858
928
|
}
|
|
@@ -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). */
|