@reactionary/ucp 0.10.35 → 0.10.37

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.
@@ -0,0 +1,88 @@
1
+ import * as z from "zod";
2
+ import {
3
+ createUcpWarning,
4
+ getLineDiscount,
5
+ getMoneyValue
6
+ } from "./reactionary-ucp-mapping.js";
7
+ const DiscountsRequestSchema = z.looseObject({
8
+ codes: z.array(z.string()).optional()
9
+ });
10
+ function getRequestedDiscountCodes(body) {
11
+ const discounts = DiscountsRequestSchema.safeParse(body["discounts"]);
12
+ return discounts.success ? discounts.data.codes : void 0;
13
+ }
14
+ async function applyDiscountCodes(client, cart, codes) {
15
+ const cartCapability = client.cart;
16
+ const messages = [];
17
+ let current = cart;
18
+ if (!cartCapability?.applyCouponCode || !cartCapability.removeCouponCode) {
19
+ return { cart, messages: codes.map((code, index) => rejectedCode(code, index)) };
20
+ }
21
+ const requested = new Set(codes.map((code) => code.toUpperCase()));
22
+ for (const code of getAppliedCodes(current)) {
23
+ if (!requested.has(code.toUpperCase())) {
24
+ const removed = await cartCapability.removeCouponCode({ cart: current.identifier, couponCode: code });
25
+ if (removed.success) {
26
+ current = removed.value;
27
+ }
28
+ }
29
+ }
30
+ const seen = /* @__PURE__ */ new Set();
31
+ for (const [index, code] of codes.entries()) {
32
+ const normalized = code.toUpperCase();
33
+ const applied = getAppliedCodes(current).some((candidate) => candidate.toUpperCase() === normalized);
34
+ if (seen.has(normalized) || applied) {
35
+ seen.add(normalized);
36
+ continue;
37
+ }
38
+ seen.add(normalized);
39
+ const result = await applyCode(cartCapability, current, code);
40
+ if (result) {
41
+ current = result;
42
+ } else {
43
+ messages.push(rejectedCode(code, index));
44
+ }
45
+ }
46
+ return { cart: current, messages };
47
+ }
48
+ async function applyCode(cartCapability, cart, code) {
49
+ for (const candidate of /* @__PURE__ */ new Set([code, code.toUpperCase()])) {
50
+ const result = await cartCapability.applyCouponCode?.({ cart: cart.identifier, couponCode: candidate });
51
+ if (result?.success) {
52
+ return result.value;
53
+ }
54
+ }
55
+ return void 0;
56
+ }
57
+ function toUcpDiscounts(cart) {
58
+ const promotions = cart.appliedPromotions;
59
+ const lineDiscounts = cart.items.map(getLineDiscount);
60
+ const itemsDiscount = lineDiscounts.reduce((sum, amount) => sum + amount, 0);
61
+ const totalDiscount = Math.max(Math.abs(getMoneyValue(cart.price.totalDiscount)), itemsDiscount);
62
+ const soleLineAllocated = promotions.length === 1 && totalDiscount === itemsDiscount && itemsDiscount > 0;
63
+ return {
64
+ discounts: {
65
+ codes: getAppliedCodes(cart),
66
+ applied: promotions.map((promotion) => ({
67
+ ...promotion.isCouponCode ? { code: promotion.code } : {},
68
+ title: promotion.name || promotion.code || "Discount",
69
+ amount: promotion.amount ? Math.abs(getMoneyValue(promotion.amount)) : promotions.length === 1 ? totalDiscount : 0,
70
+ automatic: !promotion.isCouponCode,
71
+ ...soleLineAllocated ? {
72
+ allocations: lineDiscounts.flatMap((amount, index) => amount > 0 ? [{ path: `$.line_items[${index}]`, amount }] : [])
73
+ } : {}
74
+ }))
75
+ }
76
+ };
77
+ }
78
+ function getAppliedCodes(cart) {
79
+ return cart.appliedPromotions.filter((promotion) => promotion.isCouponCode && promotion.code).map((promotion) => promotion.code);
80
+ }
81
+ function rejectedCode(code, index) {
82
+ return createUcpWarning("discount_code_invalid", `Discount code '${code}' could not be applied.`, `$.discounts.codes[${index}]`);
83
+ }
84
+ export {
85
+ applyDiscountCodes,
86
+ getRequestedDiscountCodes,
87
+ toUcpDiscounts
88
+ };
@@ -1,3 +1,4 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
1
2
  const UCP_SESSION_ID_HEADER = "ucp-session-id";
2
3
  function getSessionId(request) {
3
4
  return request.headers.get(UCP_SESSION_ID_HEADER) ?? void 0;
@@ -70,10 +71,16 @@ async function sendWebResponse(response, webResponse) {
70
71
  const body = Buffer.from(await webResponse.arrayBuffer());
71
72
  response.end(body);
72
73
  }
74
+ function secureEquals(left, right) {
75
+ const leftDigest = createHash("sha256").update(left).digest();
76
+ const rightDigest = createHash("sha256").update(right).digest();
77
+ return timingSafeEqual(leftDigest, rightDigest);
78
+ }
73
79
  export {
74
80
  UCP_SESSION_ID_HEADER,
75
81
  getOrCreateSessionId,
76
82
  jsonResponse,
83
+ secureEquals,
77
84
  sendWebResponse,
78
85
  toWebRequest
79
86
  };
@@ -1,7 +1,7 @@
1
1
  import { SessionSchema } from "@reactionary/core";
2
- import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { createHash, randomBytes } from "node:crypto";
3
3
  import * as z from "zod";
4
- import { jsonResponse } from "./reactionary-ucp-http.js";
4
+ import { jsonResponse, secureEquals } from "./reactionary-ucp-http.js";
5
5
  import {
6
6
  hashIdentityToken,
7
7
  ReactionaryUCPIdentityState,
@@ -404,11 +404,6 @@ function verifyPkce(codeVerifier, codeChallenge) {
404
404
  const computed = createHash("sha256").update(codeVerifier).digest("base64url");
405
405
  return secureEquals(computed, codeChallenge);
406
406
  }
407
- function secureEquals(left, right) {
408
- const leftDigest = createHash("sha256").update(left).digest();
409
- const rightDigest = createHash("sha256").update(right).digest();
410
- return timingSafeEqual(leftDigest, rightDigest);
411
- }
412
407
  function redirectResponse(location) {
413
408
  return new Response(null, {
414
409
  status: 302,
@@ -0,0 +1,92 @@
1
+ import { LanguageContextSchema } from "@reactionary/core";
2
+ const DEFAULT_UCP_LOCALIZATION_RULES = [
3
+ { country: "SE", languageContext: { locale: "sv-SE", currencyCode: "SEK" } },
4
+ { country: "NO", languageContext: { locale: "nb-NO", currencyCode: "NOK" } },
5
+ { country: "DK", languageContext: { locale: "da-DK", currencyCode: "DKK" } },
6
+ { country: "FI", languageContext: { locale: "fi-FI", currencyCode: "EUR" } },
7
+ { language: "sv", languageContext: { locale: "sv-SE", currencyCode: "SEK" } },
8
+ { language: "nb", languageContext: { locale: "nb-NO", currencyCode: "NOK" } },
9
+ { language: "nn", languageContext: { locale: "nb-NO", currencyCode: "NOK" } },
10
+ { language: "no", languageContext: { locale: "nb-NO", currencyCode: "NOK" } },
11
+ { language: "da", languageContext: { locale: "da-DK", currencyCode: "DKK" } },
12
+ { language: "fi", languageContext: { locale: "fi-FI", currencyCode: "EUR" } },
13
+ { language: "en", languageContext: { locale: "en-US", currencyCode: "USD" } }
14
+ ];
15
+ const SESSION_LANGUAGE_CONTEXT_KEY = "dev.ucp.languageContext";
16
+ async function resolveLanguageContext(request, session, options) {
17
+ if (!options) {
18
+ return void 0;
19
+ }
20
+ const stored = LanguageContextSchema.safeParse(session[SESSION_LANGUAGE_CONTEXT_KEY]);
21
+ if (stored.success) {
22
+ return stored.data;
23
+ }
24
+ const resolved = await negotiateLanguageContext(request, options);
25
+ if (resolved) {
26
+ session[SESSION_LANGUAGE_CONTEXT_KEY] = resolved;
27
+ }
28
+ return resolved;
29
+ }
30
+ async function negotiateLanguageContext(request, options) {
31
+ const country = await peekContextCountry(request);
32
+ if (country) {
33
+ const byCountry = options.rules.find(
34
+ (rule) => rule.country && rule.country.toUpperCase() === country
35
+ );
36
+ if (byCountry) {
37
+ return byCountry.languageContext;
38
+ }
39
+ }
40
+ for (const language of parseAcceptLanguage(request.headers.get("accept-language"))) {
41
+ const byLanguage = options.rules.find(
42
+ (rule) => rule.language && languageMatches(rule.language, language)
43
+ );
44
+ if (byLanguage) {
45
+ return byLanguage.languageContext;
46
+ }
47
+ }
48
+ return options.fallback;
49
+ }
50
+ async function peekContextCountry(request) {
51
+ if (request.method !== "POST" && request.method !== "PUT" || !request.body) {
52
+ return void 0;
53
+ }
54
+ try {
55
+ const body = await request.clone().json();
56
+ if (typeof body !== "object" || body === null) {
57
+ return void 0;
58
+ }
59
+ const context = body["context"];
60
+ if (typeof context !== "object" || context === null) {
61
+ return void 0;
62
+ }
63
+ const countryValue = context["address_country"];
64
+ return typeof countryValue === "string" && countryValue.length > 0 ? countryValue.toUpperCase() : void 0;
65
+ } catch {
66
+ return void 0;
67
+ }
68
+ }
69
+ function parseAcceptLanguage(header) {
70
+ if (!header) {
71
+ return [];
72
+ }
73
+ return header.split(",").map((entry) => {
74
+ const [tag, ...parameters] = entry.trim().split(";");
75
+ const qualityParameter = parameters.map((parameter) => parameter.trim()).find((parameter) => parameter.startsWith("q="));
76
+ const quality = qualityParameter ? Number.parseFloat(qualityParameter.slice(2)) : 1;
77
+ return {
78
+ tag: tag.trim(),
79
+ quality: Number.isFinite(quality) ? quality : 0
80
+ };
81
+ }).filter((entry) => entry.tag.length > 0 && entry.tag !== "*" && entry.quality > 0).sort((left, right) => right.quality - left.quality).map((entry) => entry.tag);
82
+ }
83
+ function languageMatches(ruleLanguage, acceptedTag) {
84
+ const rule = ruleLanguage.toLowerCase();
85
+ const tag = acceptedTag.toLowerCase();
86
+ return rule === tag || tag.startsWith(`${rule}-`) || rule.startsWith(`${tag}-`);
87
+ }
88
+ export {
89
+ DEFAULT_UCP_LOCALIZATION_RULES,
90
+ parseAcceptLanguage,
91
+ resolveLanguageContext
92
+ };
@@ -0,0 +1,186 @@
1
+ const UCP_VERSION = "2026-08-25";
2
+ function getSelectedPaymentInstrument(checkout) {
3
+ const instruments = checkout.payment?.instruments ?? [];
4
+ return instruments.find((instrument) => instrument.selected) ?? instruments[0];
5
+ }
6
+ function toOrderPermalinkUrl(merchantUrl, orderId) {
7
+ if (!merchantUrl) {
8
+ return "";
9
+ }
10
+ return `${merchantUrl.replace(/\/+$/, "")}/orders/${encodeURIComponent(orderId)}`;
11
+ }
12
+ function toReactionaryAddress(address) {
13
+ return {
14
+ firstName: address.first_name ?? "",
15
+ lastName: address.last_name ?? "",
16
+ streetAddress: address.street_address ?? "",
17
+ streetNumber: "",
18
+ city: address.address_locality ?? "",
19
+ region: address.address_region ?? "",
20
+ postalCode: address.postal_code ?? "",
21
+ countryCode: address.address_country ?? ""
22
+ };
23
+ }
24
+ function getLineSubtotal(lineItem) {
25
+ return getMoneyValue({
26
+ value: lineItem.price.unitPrice.value * lineItem.quantity,
27
+ currency: lineItem.price.unitPrice.currency
28
+ });
29
+ }
30
+ function getLineDiscount(lineItem) {
31
+ return Math.abs(getMoneyValue(lineItem.price.totalDiscount));
32
+ }
33
+ function toUcpCartLineItem(lineItem) {
34
+ const sku = lineItem.variant.sku || lineItem.identifier.key;
35
+ const discount = getLineDiscount(lineItem);
36
+ return {
37
+ id: lineItem.identifier.key,
38
+ item: {
39
+ id: sku,
40
+ title: sku,
41
+ price: getMoneyValue(lineItem.price.unitPrice)
42
+ },
43
+ quantity: lineItem.quantity,
44
+ totals: [
45
+ { type: "subtotal", amount: getLineSubtotal(lineItem) },
46
+ ...discount > 0 ? [{ type: "items_discount", amount: -discount }] : [],
47
+ ...toUcpTotals(lineItem.price.totalPrice)
48
+ ]
49
+ };
50
+ }
51
+ function toUcpCostTotals(price, items = []) {
52
+ const subtotal = items.length > 0 ? items.reduce((sum, item) => sum + getLineSubtotal(item), 0) : getMoneyValue(price.totalProductPrice);
53
+ const itemsDiscount = items.reduce((sum, item) => sum + getLineDiscount(item), 0);
54
+ const orderDiscount = Math.max(Math.abs(getMoneyValue(price.totalDiscount)) - itemsDiscount, 0);
55
+ const shipping = getMoneyValue(price.totalShipping);
56
+ const fee = getMoneyValue(price.totalSurcharge);
57
+ const grandTotal = getMoneyValue(price.grandTotal);
58
+ const taxIncluded = subtotal - itemsDiscount - orderDiscount + shipping + fee === grandTotal;
59
+ const totals = [
60
+ { type: "subtotal", amount: subtotal }
61
+ ];
62
+ const optionalTotals = [
63
+ ["items_discount", -itemsDiscount],
64
+ ["discount", -orderDiscount],
65
+ ["fulfillment", shipping],
66
+ ["tax", taxIncluded ? 0 : getMoneyValue(price.totalTax)],
67
+ ["fee", fee]
68
+ ];
69
+ for (const [type, amount] of optionalTotals) {
70
+ if (amount !== 0) {
71
+ totals.push({ type, amount });
72
+ }
73
+ }
74
+ totals.push({ type: "total", amount: grandTotal });
75
+ return totals;
76
+ }
77
+ function toUcpTotals(amount) {
78
+ return [
79
+ {
80
+ type: "total",
81
+ amount: getMoneyValue(amount)
82
+ }
83
+ ];
84
+ }
85
+ const CURRENCY_EXPONENTS = {
86
+ BIF: 0,
87
+ CLP: 0,
88
+ DJF: 0,
89
+ GNF: 0,
90
+ ISK: 0,
91
+ JPY: 0,
92
+ KMF: 0,
93
+ KRW: 0,
94
+ PYG: 0,
95
+ RWF: 0,
96
+ UGX: 0,
97
+ UYI: 0,
98
+ VND: 0,
99
+ VUV: 0,
100
+ XAF: 0,
101
+ XOF: 0,
102
+ XPF: 0,
103
+ BHD: 3,
104
+ IQD: 3,
105
+ JOD: 3,
106
+ KWD: 3,
107
+ LYD: 3,
108
+ OMR: 3,
109
+ TND: 3
110
+ };
111
+ function getMoneyValue(amount) {
112
+ const exponent = CURRENCY_EXPONENTS[amount.currency] ?? 2;
113
+ return Math.round(amount.value * 10 ** exponent);
114
+ }
115
+ function getMoneyCurrency(amount) {
116
+ return amount.currency;
117
+ }
118
+ function createUcpSuccessMetadata() {
119
+ return {
120
+ version: UCP_VERSION,
121
+ status: "success"
122
+ };
123
+ }
124
+ function createUcpCheckoutSuccessMetadata(paymentHandlers) {
125
+ const candidate = paymentHandlers;
126
+ return {
127
+ version: UCP_VERSION,
128
+ status: "success",
129
+ payment_handlers: isUcpCheckoutPaymentHandlers(candidate) ? candidate : {}
130
+ };
131
+ }
132
+ function isUcpCheckoutPaymentHandlers(value) {
133
+ return typeof value === "object" && value !== null && Object.values(value).every(
134
+ (handlers) => Array.isArray(handlers) && handlers.every(
135
+ (handler) => typeof handler === "object" && handler !== null && typeof Reflect.get(handler, "version") === "string"
136
+ )
137
+ );
138
+ }
139
+ function createUcpErrorMessage(code, message, path) {
140
+ const unrecoverable = code.endsWith("not_found") || code === "not_available" || code === "not_implemented";
141
+ return {
142
+ type: "error",
143
+ code,
144
+ ...path ? { path } : {},
145
+ content_type: "plain",
146
+ content: message,
147
+ severity: unrecoverable ? "unrecoverable" : "recoverable"
148
+ };
149
+ }
150
+ function createUCPError(code, message, path) {
151
+ return {
152
+ ucp: {
153
+ version: UCP_VERSION,
154
+ status: "error"
155
+ },
156
+ messages: [createUcpErrorMessage(code, message, path)]
157
+ };
158
+ }
159
+ function createUcpWarning(code, content, path) {
160
+ return {
161
+ type: "warning",
162
+ code,
163
+ path,
164
+ content,
165
+ content_type: "plain",
166
+ presentation: "notice"
167
+ };
168
+ }
169
+ export {
170
+ UCP_VERSION,
171
+ createUCPError,
172
+ createUcpCheckoutSuccessMetadata,
173
+ createUcpErrorMessage,
174
+ createUcpSuccessMetadata,
175
+ createUcpWarning,
176
+ getLineDiscount,
177
+ getLineSubtotal,
178
+ getMoneyCurrency,
179
+ getMoneyValue,
180
+ getSelectedPaymentInstrument,
181
+ toOrderPermalinkUrl,
182
+ toReactionaryAddress,
183
+ toUcpCartLineItem,
184
+ toUcpCostTotals,
185
+ toUcpTotals
186
+ };
@@ -0,0 +1,152 @@
1
+ import * as z from "zod";
2
+ import {
3
+ createUCPError,
4
+ createUcpSuccessMetadata,
5
+ getMoneyCurrency,
6
+ toOrderPermalinkUrl,
7
+ toUcpCartLineItem,
8
+ toUcpCostTotals
9
+ } from "./reactionary-ucp-mapping.js";
10
+ import {
11
+ UCPAdjustmentSchema,
12
+ UCPFulfillmentEventSchema
13
+ } from "./reactionary-ucp-session-store.js";
14
+ const UCPOrderUpdateSchema = z.looseObject({
15
+ fulfillment: z.looseObject({
16
+ events: z.array(UCPFulfillmentEventSchema).optional()
17
+ }).optional(),
18
+ adjustments: z.array(UCPAdjustmentSchema).optional()
19
+ });
20
+ async function getOrder(context, orderId) {
21
+ const result = await getBackendOrder(context, orderId);
22
+ if (!("identifier" in result)) {
23
+ return result;
24
+ }
25
+ return toUcpOrder(context, result, await context.store.getOrder(orderId));
26
+ }
27
+ async function updateOrder(context, orderId, update) {
28
+ const result = await getBackendOrder(context, orderId);
29
+ const state = await context.store.getOrder(orderId);
30
+ if (!("identifier" in result)) {
31
+ return result;
32
+ }
33
+ if (!state) {
34
+ return createUCPError("not_found", `Order was not placed through a UCP checkout session: ${orderId}`);
35
+ }
36
+ const updated = {
37
+ ...state,
38
+ ...update.fulfillment?.events ? { events: update.fulfillment.events } : {},
39
+ ...update.adjustments ? { adjustments: update.adjustments } : {}
40
+ };
41
+ await context.store.putOrder(updated);
42
+ const order = toUcpOrder(context, result, updated);
43
+ notifyOrder(context, updated, order);
44
+ return order;
45
+ }
46
+ async function simulateShipping(context, orderId) {
47
+ const result = await getBackendOrder(context, orderId);
48
+ const state = await context.store.getOrder(orderId);
49
+ if (!("identifier" in result)) {
50
+ return result;
51
+ }
52
+ if (!state) {
53
+ return createUCPError("not_found", `Order was not placed through a UCP checkout session: ${orderId}`);
54
+ }
55
+ return updateOrder(context, orderId, {
56
+ fulfillment: {
57
+ events: [
58
+ ...state.events,
59
+ {
60
+ id: `evt_${crypto.randomUUID()}`,
61
+ occurred_at: (/* @__PURE__ */ new Date()).toISOString(),
62
+ type: "shipped",
63
+ line_items: result.items.filter((item) => item.quantity > 0).map((item) => ({ id: item.identifier.key, quantity: item.quantity })),
64
+ tracking_number: `SIM-${orderId}`,
65
+ description: "Simulated shipment"
66
+ }
67
+ ]
68
+ }
69
+ });
70
+ }
71
+ async function notifyOrderPlaced(context, orderId) {
72
+ const state = await context.store.getOrder(orderId);
73
+ if (!context.webhooks || !state?.agentProfile) {
74
+ return;
75
+ }
76
+ const webhookUrl = await context.webhooks.resolveOrderWebhookUrl(state.agentProfile);
77
+ if (!webhookUrl) {
78
+ return;
79
+ }
80
+ const placed = { ...state, webhookUrl };
81
+ await context.store.putOrder(placed);
82
+ const order = await getOrder(context, orderId);
83
+ if ("id" in order) {
84
+ notifyOrder(context, placed, order);
85
+ }
86
+ }
87
+ function notifyOrder(context, state, order) {
88
+ if (context.webhooks && state.webhookUrl) {
89
+ context.webhooks.deliver(state.webhookUrl, order);
90
+ }
91
+ }
92
+ async function getBackendOrder(context, orderId) {
93
+ if (!context.client.order) {
94
+ return createUCPError("not_available", "Order capability is not available.");
95
+ }
96
+ const result = await context.client.order.getById({ order: { key: orderId } });
97
+ return result.success ? result.value : createUCPError("not_found", `Order was not found: ${orderId}`);
98
+ }
99
+ function toUcpOrder(context, order, state) {
100
+ const events = state?.events ?? [];
101
+ const lineItems = order.items.map((item) => {
102
+ const total = item.quantity;
103
+ const shipped = events.filter((event) => event.type === "shipped").flatMap((event) => event.line_items).filter((reference) => reference.id === item.identifier.key).reduce((sum, reference) => sum + reference.quantity, 0);
104
+ const fulfilled = Math.min(shipped, total);
105
+ return {
106
+ ...toUcpCartLineItem(item),
107
+ quantity: { original: total, total, fulfilled },
108
+ status: total === 0 ? "removed" : fulfilled === total ? "fulfilled" : fulfilled > 0 ? "partial" : "processing"
109
+ };
110
+ });
111
+ const destination = order.shippingAddress ? toUcpPostalAddress(order.shippingAddress) : state?.destination;
112
+ const description = state?.fulfillmentTitle ?? order.shippingMethod?.name;
113
+ return {
114
+ ucp: createUcpSuccessMetadata(),
115
+ id: order.identifier.key,
116
+ checkout_id: state?.checkoutSessionId ?? "",
117
+ permalink_url: toOrderPermalinkUrl(context.merchantUrl, order.identifier.key),
118
+ line_items: lineItems,
119
+ currency: getMoneyCurrency(order.price.grandTotal),
120
+ totals: toUcpCostTotals(order.price, order.items),
121
+ fulfillment: {
122
+ expectations: destination ? [{
123
+ id: "expectation_1",
124
+ line_items: lineItems.map((lineItem) => ({ id: lineItem.id, quantity: lineItem.quantity.total })),
125
+ method_type: "shipping",
126
+ destination,
127
+ ...description ? { description } : {},
128
+ fulfillable_on: "now"
129
+ }] : [],
130
+ events
131
+ },
132
+ adjustments: state?.adjustments ?? []
133
+ };
134
+ }
135
+ function toUcpPostalAddress(address) {
136
+ return {
137
+ first_name: address.firstName,
138
+ last_name: address.lastName,
139
+ street_address: [address.streetAddress, address.streetNumber].filter(Boolean).join(" "),
140
+ address_locality: address.city,
141
+ address_region: address.region,
142
+ postal_code: address.postalCode,
143
+ address_country: address.countryCode
144
+ };
145
+ }
146
+ export {
147
+ UCPOrderUpdateSchema,
148
+ getOrder,
149
+ notifyOrderPlaced,
150
+ simulateShipping,
151
+ updateOrder
152
+ };
@@ -64,13 +64,20 @@ function createUcpCapabilities(client) {
64
64
  }
65
65
  if (hasCapability(client, "cart")) {
66
66
  capabilities["dev.ucp.shopping.cart"] = [createUcpCapability("cart", "cart")];
67
- const cart = getCapability(client, "cart");
68
- if (cart && typeof Reflect.get(cart, "applyCouponCode") === "function") {
69
- capabilities["dev.ucp.shopping.discount"] = [createUcpCapability("extensions/discount", "discount")];
70
- }
71
67
  }
72
68
  if (hasCapability(client, "checkout")) {
73
69
  capabilities["dev.ucp.shopping.checkout"] = [createUcpCapability("checkout", "checkout")];
70
+ const checkout = getCapability(client, "checkout");
71
+ if (checkout && typeof Reflect.get(checkout, "getAvailableShippingMethods") === "function") {
72
+ capabilities["dev.ucp.shopping.fulfillment"] = [createUcpCapability("extensions/fulfillment", "fulfillment")];
73
+ }
74
+ const cart = getCapability(client, "cart");
75
+ if (cart && typeof Reflect.get(cart, "applyCouponCode") === "function") {
76
+ capabilities["dev.ucp.shopping.discount"] = [{
77
+ ...createUcpCapability("extensions/discount", "discount"),
78
+ extends: "dev.ucp.shopping.checkout"
79
+ }];
80
+ }
74
81
  }
75
82
  if (hasCapability(client, "order")) {
76
83
  capabilities["dev.ucp.shopping.order"] = [createUcpCapability("order", "order")];