@asstio-dev/storefront 0.15.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -360,8 +360,8 @@ answers the converted cart's completed attempt) with the same token, navigate to
360
360
  `order`, and clear the cookie only once the confirmation page holds the access token.
361
361
 
362
362
  **Copy and CSP.** The `pending` state ("awaiting payment approval") is your copy. Each provider's widget needs its
363
- domains in your CSP: Qliro One `*.qliro.com` (`pago.qit.nu` in the sandbox), Klarna/Kustom `*.klarna.com`
364
- (`*.kustom.co`), Walley `*.walleypay.com`, plus their `frame-src`/`script-src`/`connect-src` — the provider's
363
+ domains in your CSP: Qliro One `*.qliro.com` (`pago.qit.nu` in the sandbox), Kustom `*.kustom.co` (verified, see
364
+ below), Walley `*.walleypay.com`, plus their `frame-src`/`script-src`/`connect-src` — the provider's
365
365
  integration guide lists the exact hosts.
366
366
 
367
367
  Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `cart`), `CHECKOUT_PREVIEW_KEY`,
@@ -375,8 +375,10 @@ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `c
375
375
  - The widget is Qliro's HTML snippet; `mountCheckout` re-creates its scripts so they run.
376
376
  - After every cart change, `handle.sync(update)` locks the widget (`q1.lock()`), and resumes it through
377
377
  `q1.onOrderUpdated`, which makes the iframe re-read the order. The widget unlocks only when Qliro's order shows the
378
- confirmed total. If it does not within 10 seconds, or the change came before Qliro's script had loaded, the widget is
379
- re-rendered from the confirmed checkout instead; it is never unlocked before Qliro shows the confirmed total.
378
+ confirmed total, or echoes the confirmed cart version (`MerchantUpdateVersion`, which the backend sends on a market
379
+ with shipping, where Qliro's total includes the fee). If it does neither within 10 seconds, or the change came before
380
+ Qliro's script had loaded, the widget is re-rendered from the confirmed checkout instead; it is never unlocked before
381
+ Qliro shows the confirmed total or version.
380
382
  - When our validation declines a purchase (the cart moved on, the attempt expired, a line is out of stock), Qliro shows
381
383
  its out-of-stock message and fires `q1.onPaymentDeclined`; the SDK then syncs by itself and remounts or resumes.
382
384
 
@@ -397,9 +399,56 @@ Problem codes you will meet here: `CART_EMPTY`, `CHECKOUT_CART_CHANGED` (with `c
397
399
  server's; the page does nothing for them.
398
400
  - The checkout page needs a `checkoutPath` and a `termsPath` in the market's checkout block (Purchase refuses a Kustom
399
401
  option without them).
400
- - **CSP hosts: pending K-C10** (`docs/ops/kustom-playground-checks.md`). Until the playground run records the hosts the
401
- snippet really loads (card with 3-D Secure and the default method), allow `*.kustom.co` and `*.klarna.com` for
402
- `script-src`, `frame-src` and `connect-src` as the general paragraph above says, and watch the console for blocked requests.
402
+ - Never bind `can_not_complete_order` yourself through `window._klarnaCheckout(api => api.on(...))`. Kustom's `api.on`
403
+ replaces the listener per event instead of adding one, so yours and the SDK's would replace each other. Use
404
+ `onProblem`. Listeners for other events are not affected.
405
+ - **CSP hosts** (K-C10, `docs/ops/kustom-playground-checks.md`, 2026-10-09: a card with 3-D Secure and the default
406
+ method, no blocked request). The checkout page itself loads only Kustom's script host. Everything else
407
+ (`kco.assets.kustomcdn.co`, Klarna, Stripe, hCaptcha, Google Pay, Amazon Pay) loads inside Kustom's iframe under
408
+ Kustom's own policy. Allow:
409
+ - `script-src 'unsafe-inline' https://*.kustom.co`;
410
+ - `frame-src https://*.kustom.co`;
411
+ - `connect-src https://*.kustom.co`.
412
+
413
+ `https://*.kustom.co` covers the playground (`js.playground.kustom.co`) and production (`js.kustom.co`).
414
+
415
+ ### Shipping
416
+
417
+ The shopper chooses delivery inside the provider's widget (Kustom's shipping assistant, Qliro's shipping step), so
418
+ there is **no shipping picker to build and no price, VAT or method id to send**. The `klarna-kco` (Kustom) and `qliro-q1`
419
+ controllers answer for the widget; the page's part is the estimate before the checkout and the shipping block on the order.
420
+
421
+ ```ts
422
+ // The estimate: an estimate, never a quote (the destination is not known yet). No lookup, `no-store`.
423
+ const { data: estimate } = await storefront.GET("/v1/cart/shipping-estimate", {
424
+ params: { header: { "X-Cart-Token": token } },
425
+ });
426
+ if (estimate?.available) {
427
+ const headline = estimate.options.find((option) => option.preselected); // `fee` is gross kronor, "49.00"
428
+ // estimate.freeShipping: { free, threshold, remaining } — progress comes from here, never a hard-coded threshold
429
+ }
430
+ // `available: false` means show nothing about shipping. `reason: null`: no block published on the market, and the
431
+ // checkout works as before. `reason: "unavailable"`: a block that cannot be served now; the cart works, but
432
+ // POST /v1/checkout refuses a new attempt with 503 SHIPPING_UNAVAILABLE (Retry-After 60) until it is fixed.
433
+
434
+ // The order page: `order.shipping` is `OrderShippingDto | null`.
435
+ // null = the selection was not matched or confirmed (the charged shipping line is still in `order.lines`), or the
436
+ // market has no block. Show the block only when it is present; never read null as "no shipping was paid".
437
+ ```
438
+
439
+ - `ShippingEstimateOptionDto`: `key`, `name`, `deliveryType` (`delivery`, `pickup` or `mailbox`), `fee`, `regularFee`
440
+ (the price without the free-shipping rule; set only when `fee` is `"0.00"` and that price is above 0, so `null`
441
+ for an always-free method: render no comparison price then), `labels`, `pointsAtCheckout` (the pickup point is
442
+ chosen in the checkout; none is listed here) and `preselected`. The estimate has **no delivery time**.
443
+ - `OrderShippingDto`: `option`, `name`, `deliveryType`, `carrier`, `fee`, `point` (name, street, postal code, city; null
444
+ when the carrier assigned it or after the privacy scrub) and `deliveryTime` (`mode` `carrierEstimate`, `fixed` or
445
+ `text`; `minDays` and `maxDays` are each nullable: render only the bounds that are present, or `text`).
446
+ - **Do not re-render the widget** when shipping changes, and do not write your own Qliro unlock: on a market with a
447
+ shipping block Qliro's total includes the fee and so can differ from the cart's, and the `qliro-q1` controller unlocks on the echoed cart version
448
+ (`MerchantUpdateVersion`) or on the confirmed total (see the Qliro notes above).
449
+ - Shipping never blocks a purchase. If a selection cannot be matched when the shopper pays, the order is accepted and
450
+ normally held in Asstio with its reason (rarely the export waits for an operator instead); the page has nothing to
451
+ do for it.
403
452
 
404
453
  ## Headers
405
454
 
@@ -357,7 +357,7 @@ export function mountCheckout(container, checkout, options) {
357
357
  // until its own answer *(plan review 2 #11)*. The provider unlocks once its view shows `dto.cart`; if it
358
358
  // never does, the widget is re-rendered from this answer *(plan review 1 #3)*.
359
359
  if (active <= 1)
360
- resume({ cart: dto.cart, onUnsynced: () => { if (alive(gen))
360
+ resume({ cart: dto.cart, cartVersion: Number(dto.cartVersion), onUnsynced: () => { if (alive(gen))
361
361
  remount(dto); } });
362
362
  }
363
363
  else {
@@ -37,6 +37,9 @@ export interface ResumeTarget {
37
37
  cart: {
38
38
  grandTotalInc: string;
39
39
  };
40
+ /** The confirmed answer's cart version. On a shipping market Qliro's PUT carries it as `MerchantUpdateVersion`, so its
41
+ * echo in Qliro's order also shows the update landed (carriers 1b ruling R-S3-K1). */
42
+ cartVersion?: number;
40
43
  /** The provider never showed `cart` before its deadline: the handle re-renders the widget from the confirmed answer. */
41
44
  onUnsynced(): void;
42
45
  }
@@ -66,7 +69,9 @@ export type ControllerFactory = (win: ProviderWindow, timer: ControllerTimer) =>
66
69
  * re-rendered, never unlocked blindly *(plan review 1 #3)*. */
67
70
  export declare const QLIRO_SYNC_DEADLINE_MS = 10000;
68
71
  /** Qliro's check before `unlock()` (*Update order*: "Verify that the total amount of the order = total amount in the
69
- * shopping cart"), in öre. The total alone, as Qliro documents it *(plan review 1 #3, final review S1)*. */
70
- export declare function qliroOrderMatches(order: unknown, cart: ResumeTarget["cart"]): boolean;
72
+ * shopping cart"), in öre, **or** the `MerchantUpdateVersion` our PUT sent (carriers 1b ruling R-S3-K1: on a shipping
73
+ * market Qliro's total includes the fee, so the total alone never matches). The echo is a string in Qliro's schema; its
74
+ * name is read case-insensitively (VQ-8 records the real one). */
75
+ export declare function qliroOrderMatches(order: unknown, target: Pick<ResumeTarget, "cart" | "cartVersion">): boolean;
71
76
  export declare const controllers: Record<"qliro-q1" | "klarna-kco" | "walley", ControllerFactory>;
72
77
  export declare function controllerFor(name: string, win: ProviderWindow, timer?: ControllerTimer): CheckoutController;
@@ -5,10 +5,23 @@ export const KLARNA_READY_POLL_MS = 100;
5
5
  export const QLIRO_SYNC_DEADLINE_MS = 10_000;
6
6
  const ore = (v) => Math.round(Number(v) * 100);
7
7
  /** Qliro's check before `unlock()` (*Update order*: "Verify that the total amount of the order = total amount in the
8
- * shopping cart"), in öre. The total alone, as Qliro documents it *(plan review 1 #3, final review S1)*. */
9
- export function qliroOrderMatches(order, cart) {
10
- const total = order?.totalPrice;
11
- return (typeof total === "number" || typeof total === "string") && ore(total) === ore(cart.grandTotalInc);
8
+ * shopping cart"), in öre, **or** the `MerchantUpdateVersion` our PUT sent (carriers 1b ruling R-S3-K1: on a shipping
9
+ * market Qliro's total includes the fee, so the total alone never matches). The echo is a string in Qliro's schema; its
10
+ * name is read case-insensitively (VQ-8 records the real one). */
11
+ export function qliroOrderMatches(order, target) {
12
+ const o = order;
13
+ const total = o?.totalPrice;
14
+ if ((typeof total === "number" || typeof total === "string") && ore(total) === ore(target.cart.grandTotalInc))
15
+ return true;
16
+ if (target.cartVersion === undefined || !o)
17
+ return false;
18
+ const key = Object.keys(o).find((k) => k.toLowerCase() === "merchantupdateversion");
19
+ const raw = key === undefined ? undefined : o[key];
20
+ // Only a number or a non-blank string is an echo: `Number("")` and `Number(null)` are 0, which must not match version 0.
21
+ if (!(typeof raw === "number" || (typeof raw === "string" && raw.trim() !== "")))
22
+ return false;
23
+ const echoed = Number(raw);
24
+ return Number.isFinite(echoed) && echoed === Number(target.cartVersion);
12
25
  }
13
26
  export const controllers = {
14
27
  "qliro-q1": (win, timer) => {
@@ -42,7 +55,7 @@ export const controllers = {
42
55
  }
43
56
  if (missedLock)
44
57
  q1.lock(); // onOrderUpdated "requires q1.lock() to have been called first"
45
- q1.onOrderUpdated((order) => { if (qliroOrderMatches(order, target.cart))
58
+ q1.onOrderUpdated((order) => { if (qliroOrderMatches(order, target))
46
59
  unlock(); });
47
60
  // Never unlocked blindly: past the deadline the handle re-renders the widget from the confirmed answer
48
61
  // *(plan review 1 #3)*.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asstio-dev/storefront",
3
- "version": "0.15.1",
3
+ "version": "0.16.3",
4
4
  "description": "Typed client for the Asstio Storefront API (generated from OpenAPI).",
5
5
  "repository": {
6
6
  "type": "git",