@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.7.2",
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(["flatRate", "freeAtOrAbove"]);
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
- if (!isShippingAmount(source.flatRate)) {
225
- problems.push(`\`shipping.flatRate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
226
- }
227
- if (source.freeAtOrAbove !== undefined && !(isShippingAmount(source.freeAtOrAbove) && /** @type {number} */ (source.freeAtOrAbove) > 0)) {
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": { "flatRate": 5.99, "freeAtOrAbove": 100 }`, or remove `shipping` to charge none'));
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
- /** A shipping policy in the minor units of one currency. */
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
- flatMinor: number;
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 freeAtOrAboveMinor = policy.freeAtOrAbove === undefined ? null : minorUnits(policy.freeAtOrAbove, digits);
259
- if (freeAtOrAboveMinor === 0) throw new ShippingPolicyUnavailableError();
260
- return { currency, digits, flatMinor: minorUnits(policy.flatRate, digits), freeAtOrAboveMinor };
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
- /** Shipping owed on an order whose item subtotal is `subtotalMinor`: the flat rate, or 0 at or above the free threshold. */
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
- const minor = shippingPolicyMinor(policy, currency);
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, e.g. `currency=USD;flat=5.99;free_at=100.00` (`free_at`
271
- * only when declared). Amounts carry exactly the currency's minor-unit digits; the checkout
272
- * store's shipping code parses this shape and refuses any other.
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}`, `flat=${amount(minor.flatMinor)}`,
278
- ...(minor.freeAtOrAboveMinor === null ? [] : [`free_at=${amount(minor.freeAtOrAboveMinor)}`])].join(";");
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(["flatRate", "freeAtOrAbove"]);
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
- if (!isShippingAmount(source.flatRate)) {
271
- problems.push(`\`shipping.flatRate\` must be an amount from 0 to ${MAX_SHIPPING_AMOUNT} with at most two decimal places`);
272
- }
273
- if (source.freeAtOrAbove !== undefined && !(isShippingAmount(source.freeAtOrAbove) && /** @type {number} */ (source.freeAtOrAbove) > 0)) {
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
- const policy = /** @type {Record<string, number>} */ (shipping);
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 flat rate per order, waived when the order's item subtotal
777
- * is at or above `freeAtOrAbove`. The cart carries it as the signed `shipping_policy` option;
778
- * the checkout store charges it and the final-cart authority re-derives it from this
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
- /** Charged once per order, in the cart's currency. `0` ships every order free. */
783
- flatRate: number;
784
- /** Item subtotal at or above which shipping is free, in the cart's currency. */
785
- freeAtOrAbove?: number;
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). */