@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.
- package/index.js +11 -0
- package/lib/reactionary-ucp-checkout-session.js +603 -0
- package/lib/reactionary-ucp-discounts.js +88 -0
- package/lib/reactionary-ucp-http.js +7 -0
- package/lib/reactionary-ucp-identity.js +2 -7
- package/lib/reactionary-ucp-localization.js +92 -0
- package/lib/reactionary-ucp-mapping.js +186 -0
- package/lib/reactionary-ucp-order.js +152 -0
- package/lib/reactionary-ucp-profile.js +11 -4
- package/lib/reactionary-ucp-rest.js +135 -225
- package/lib/reactionary-ucp-server.js +123 -4
- package/lib/reactionary-ucp-session-store.js +145 -1
- package/lib/reactionary-ucp-signing.js +72 -0
- package/lib/reactionary-ucp-webhooks.js +113 -0
- package/package.json +2 -2
- package/src/index.d.ts +4 -0
- package/src/lib/reactionary-ucp-checkout-session.d.ts +91 -0
- package/src/lib/reactionary-ucp-common.d.ts +93 -1
- package/src/lib/reactionary-ucp-discounts.d.ts +24 -0
- package/src/lib/reactionary-ucp-http.d.ts +2 -0
- package/src/lib/reactionary-ucp-localization.d.ts +35 -0
- package/src/lib/reactionary-ucp-mapping.d.ts +41 -0
- package/src/lib/reactionary-ucp-order.d.ts +77 -0
- package/src/lib/reactionary-ucp-rest.d.ts +27 -1
- package/src/lib/reactionary-ucp-server.d.ts +14 -0
- package/src/lib/reactionary-ucp-session-store.d.ts +191 -0
- package/src/lib/reactionary-ucp-signing.d.ts +27 -0
- package/src/lib/reactionary-ucp-webhooks.d.ts +48 -0
package/index.js
CHANGED
|
@@ -1,2 +1,13 @@
|
|
|
1
1
|
export * from "./lib/reactionary-ucp-server.js";
|
|
2
2
|
export * from "./lib/reactionary-ucp-identity.js";
|
|
3
|
+
export * from "./lib/reactionary-ucp-localization.js";
|
|
4
|
+
import { toPublicSigningJwk } from "./lib/reactionary-ucp-signing.js";
|
|
5
|
+
import {
|
|
6
|
+
DEFAULT_UCP_PAYMENT_AUTHORIZATION_WAIT,
|
|
7
|
+
DEFAULT_UCP_PLACEHOLDER_EMAIL
|
|
8
|
+
} from "./lib/reactionary-ucp-checkout-session.js";
|
|
9
|
+
export {
|
|
10
|
+
DEFAULT_UCP_PAYMENT_AUTHORIZATION_WAIT,
|
|
11
|
+
DEFAULT_UCP_PLACEHOLDER_EMAIL,
|
|
12
|
+
toPublicSigningJwk
|
|
13
|
+
};
|
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import {
|
|
3
|
+
createUCPError,
|
|
4
|
+
createUcpCheckoutSuccessMetadata,
|
|
5
|
+
createUcpErrorMessage,
|
|
6
|
+
createUcpWarning,
|
|
7
|
+
getMoneyCurrency,
|
|
8
|
+
getMoneyValue,
|
|
9
|
+
getSelectedPaymentInstrument,
|
|
10
|
+
toOrderPermalinkUrl,
|
|
11
|
+
toReactionaryAddress,
|
|
12
|
+
toUcpCartLineItem,
|
|
13
|
+
toUcpCostTotals
|
|
14
|
+
} from "./reactionary-ucp-mapping.js";
|
|
15
|
+
import { applyDiscountCodes, getRequestedDiscountCodes, toUcpDiscounts } from "./reactionary-ucp-discounts.js";
|
|
16
|
+
import {
|
|
17
|
+
UCPBuyerConsentSchema
|
|
18
|
+
} from "./reactionary-ucp-session-store.js";
|
|
19
|
+
const DEFAULT_UCP_PLACEHOLDER_EMAIL = "pending@checkout.invalid";
|
|
20
|
+
const DEFAULT_UCP_PAYMENT_AUTHORIZATION_WAIT = {
|
|
21
|
+
timeoutMs: 1e4,
|
|
22
|
+
intervalMs: 1e3
|
|
23
|
+
};
|
|
24
|
+
async function pollUntil(read, done, wait) {
|
|
25
|
+
const deadline = Date.now() + wait.timeoutMs;
|
|
26
|
+
let value = await read();
|
|
27
|
+
while (!done(value) && Date.now() < deadline) {
|
|
28
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(wait.intervalMs, Math.max(deadline - Date.now(), 0))));
|
|
29
|
+
value = await read();
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
const PostalAddressRequestSchema = z.looseObject({
|
|
34
|
+
id: z.string().optional(),
|
|
35
|
+
first_name: z.string().optional(),
|
|
36
|
+
last_name: z.string().optional(),
|
|
37
|
+
street_address: z.string().optional(),
|
|
38
|
+
extended_address: z.string().optional(),
|
|
39
|
+
address_locality: z.string().optional(),
|
|
40
|
+
address_region: z.string().optional(),
|
|
41
|
+
address_country: z.string().optional(),
|
|
42
|
+
postal_code: z.string().optional(),
|
|
43
|
+
phone_number: z.string().optional()
|
|
44
|
+
});
|
|
45
|
+
const FulfillmentRequestSchema = z.looseObject({
|
|
46
|
+
methods: z.array(z.looseObject({
|
|
47
|
+
selected_destination_id: z.string().nullish(),
|
|
48
|
+
destinations: z.array(PostalAddressRequestSchema).optional(),
|
|
49
|
+
groups: z.array(z.looseObject({
|
|
50
|
+
selected_option_id: z.string().nullish()
|
|
51
|
+
})).optional()
|
|
52
|
+
})).optional()
|
|
53
|
+
});
|
|
54
|
+
async function createCheckoutSession(context, body) {
|
|
55
|
+
if (!context.client.checkout || !context.client.cart) {
|
|
56
|
+
return createUCPError("not_available", "Checkout capability is not available.");
|
|
57
|
+
}
|
|
58
|
+
let cart;
|
|
59
|
+
if (body.cart_id) {
|
|
60
|
+
cart = await getCart(context, body.cart_id);
|
|
61
|
+
} else if (body.line_items?.length) {
|
|
62
|
+
cart = await context.createCart(body.line_items);
|
|
63
|
+
}
|
|
64
|
+
if (!cart) {
|
|
65
|
+
return createUCPError("invalid_request", "A checkout session requires cart_id or line_items.");
|
|
66
|
+
}
|
|
67
|
+
if (!("identifier" in cart)) {
|
|
68
|
+
return cart;
|
|
69
|
+
}
|
|
70
|
+
const discountMessages = await applyRequestedDiscountCodes(context, cart, body);
|
|
71
|
+
const state = {
|
|
72
|
+
id: `checkout_${crypto.randomUUID()}`,
|
|
73
|
+
cartId: cart.identifier.key,
|
|
74
|
+
status: "open"
|
|
75
|
+
};
|
|
76
|
+
mergeRequestIntoState(state, body);
|
|
77
|
+
await context.store.putCheckoutSession(state);
|
|
78
|
+
await context.store.bindResource(state.id, context.sessionId);
|
|
79
|
+
return (await buildOpenView(context, state, discountMessages)).response;
|
|
80
|
+
}
|
|
81
|
+
async function getCheckoutSession(context, checkoutSessionId) {
|
|
82
|
+
const state = await context.store.getCheckoutSession(checkoutSessionId);
|
|
83
|
+
if (!state) {
|
|
84
|
+
return checkoutSessionNotFound(checkoutSessionId);
|
|
85
|
+
}
|
|
86
|
+
return state.finalCheckoutId ? buildFinalView(context, state) : (await buildOpenView(context, state)).response;
|
|
87
|
+
}
|
|
88
|
+
async function updateCheckoutSession(context, checkoutSessionId, body) {
|
|
89
|
+
const state = await context.store.getCheckoutSession(checkoutSessionId);
|
|
90
|
+
if (!state) {
|
|
91
|
+
return checkoutSessionNotFound(checkoutSessionId);
|
|
92
|
+
}
|
|
93
|
+
if (state.finalCheckoutId || state.status === "canceled") {
|
|
94
|
+
return createUCPError("checkout_not_modifiable", "The checkout session can no longer be modified.");
|
|
95
|
+
}
|
|
96
|
+
if (body.line_items?.length) {
|
|
97
|
+
const failure = await context.reconcileCart(state.cartId, body.line_items);
|
|
98
|
+
if (failure) {
|
|
99
|
+
return failure;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const cart = await getCart(context, state.cartId);
|
|
103
|
+
const discountMessages = "identifier" in cart ? await applyRequestedDiscountCodes(context, cart, body) : [];
|
|
104
|
+
mergeRequestIntoState(state, body);
|
|
105
|
+
await context.store.putCheckoutSession(state);
|
|
106
|
+
return (await buildOpenView(context, state, discountMessages)).response;
|
|
107
|
+
}
|
|
108
|
+
async function applyRequestedDiscountCodes(context, cart, body) {
|
|
109
|
+
const codes = getRequestedDiscountCodes(body);
|
|
110
|
+
return codes ? (await applyDiscountCodes(context.client, cart, codes)).messages : [];
|
|
111
|
+
}
|
|
112
|
+
async function cancelCheckoutSession(context, checkoutSessionId) {
|
|
113
|
+
const state = await context.store.getCheckoutSession(checkoutSessionId);
|
|
114
|
+
if (!state) {
|
|
115
|
+
return checkoutSessionNotFound(checkoutSessionId);
|
|
116
|
+
}
|
|
117
|
+
if (state.finalCheckoutId) {
|
|
118
|
+
return createUCPError("checkout_not_modifiable", "A completed checkout session cannot be canceled.");
|
|
119
|
+
}
|
|
120
|
+
state.status = "canceled";
|
|
121
|
+
await context.store.putCheckoutSession(state);
|
|
122
|
+
return (await buildOpenView(context, state)).response;
|
|
123
|
+
}
|
|
124
|
+
async function completeCheckoutSession(context, checkoutSessionId, body) {
|
|
125
|
+
const state = await context.store.getCheckoutSession(checkoutSessionId);
|
|
126
|
+
if (!state) {
|
|
127
|
+
return checkoutSessionNotFound(checkoutSessionId);
|
|
128
|
+
}
|
|
129
|
+
if (state.status === "canceled" || state.status === "completed") {
|
|
130
|
+
return createUCPError("checkout_not_modifiable", `A ${state.status} checkout session cannot be completed.`);
|
|
131
|
+
}
|
|
132
|
+
const messages = [];
|
|
133
|
+
if (!state.finalCheckoutId) {
|
|
134
|
+
mergeRequestIntoState(state, body);
|
|
135
|
+
const { response, ready } = await buildOpenView(context, state);
|
|
136
|
+
if (!ready) {
|
|
137
|
+
return response;
|
|
138
|
+
}
|
|
139
|
+
const payment = resolvePayment(context, state, getSelectedPaymentInstrument(body)?.credential);
|
|
140
|
+
if ("failure" in payment) {
|
|
141
|
+
return addMessages(response, [payment.failure], "incomplete");
|
|
142
|
+
}
|
|
143
|
+
const placement = await placeFinalCheckout(context, state, payment);
|
|
144
|
+
if (placement.failure) {
|
|
145
|
+
return addMessages(response, [placement.failure], "incomplete");
|
|
146
|
+
}
|
|
147
|
+
messages.push(...placement.messages);
|
|
148
|
+
}
|
|
149
|
+
return finalizeIfReady(context, state, messages);
|
|
150
|
+
}
|
|
151
|
+
function resolvePayment(context, state, credential) {
|
|
152
|
+
const handlerId = state.instrument?.handler_id ?? "";
|
|
153
|
+
const testHandler = context.testPaymentHandlers?.find((handler) => handler.id === handlerId);
|
|
154
|
+
const resolved = testHandler ? { handlerId: testHandler.delegateHandlerId, credential: testHandler.resolveCredential(credential) } : { handlerId, credential };
|
|
155
|
+
if (testHandler && resolved.credential === void 0) {
|
|
156
|
+
return { failure: createUcpErrorMessage("payment_failed", "The payment credential was declined.", "$.payment.instruments") };
|
|
157
|
+
}
|
|
158
|
+
if (!context.acceptRawCardCredentials && isRawCardCredential(resolved.credential)) {
|
|
159
|
+
return {
|
|
160
|
+
failure: createUcpErrorMessage(
|
|
161
|
+
"payment_failed",
|
|
162
|
+
"Raw card credentials are not accepted; submit a delegated token from the payment handler instead.",
|
|
163
|
+
"$.payment.instruments"
|
|
164
|
+
)
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return resolved;
|
|
168
|
+
}
|
|
169
|
+
function isRawCardCredential(credential) {
|
|
170
|
+
if (typeof credential !== "object" || credential === null) {
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
const type = Reflect.get(credential, "type");
|
|
174
|
+
return type === "pan" || type === "card" || ["number", "cvc", "card_number"].some((field) => Reflect.get(credential, field) !== void 0);
|
|
175
|
+
}
|
|
176
|
+
function isAcceptedPaymentHandler(context, handlerId) {
|
|
177
|
+
const advertised = Object.values(context.paymentHandlers).flat();
|
|
178
|
+
return advertised.length === 0 || advertised.some((handler) => handler.id === handlerId) || Boolean(context.testPaymentHandlers?.some((handler) => handler.id === handlerId));
|
|
179
|
+
}
|
|
180
|
+
function mergeRequestIntoState(state, body) {
|
|
181
|
+
if (body.buyer) {
|
|
182
|
+
const consent = UCPBuyerConsentSchema.safeParse(Reflect.get(body.buyer, "consent"));
|
|
183
|
+
state.buyer = {
|
|
184
|
+
...state.buyer,
|
|
185
|
+
...body.buyer.first_name ? { first_name: body.buyer.first_name } : {},
|
|
186
|
+
...body.buyer.last_name ? { last_name: body.buyer.last_name } : {},
|
|
187
|
+
...body.buyer.email ? { email: body.buyer.email } : {},
|
|
188
|
+
...body.buyer.phone_number ? { phone_number: body.buyer.phone_number } : {},
|
|
189
|
+
...consent.success ? { consent: consent.data } : {}
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const instrument = getSelectedPaymentInstrument(body);
|
|
193
|
+
if (instrument?.billing_address) {
|
|
194
|
+
state.billingAddress = instrument.billing_address;
|
|
195
|
+
}
|
|
196
|
+
if (instrument) {
|
|
197
|
+
state.instrument = {
|
|
198
|
+
id: instrument.id,
|
|
199
|
+
handler_id: instrument.handler_id,
|
|
200
|
+
type: instrument.type
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
const fulfillment = FulfillmentRequestSchema.safeParse(body["fulfillment"]);
|
|
204
|
+
const method = fulfillment.success ? fulfillment.data.methods?.[0] : void 0;
|
|
205
|
+
if (method) {
|
|
206
|
+
const destinations = method.destinations ?? [];
|
|
207
|
+
const destination = destinations.find((candidate) => candidate.id === method.selected_destination_id) ?? (destinations.length === 1 ? destinations[0] : void 0);
|
|
208
|
+
if (destination) {
|
|
209
|
+
state.destination = destination;
|
|
210
|
+
}
|
|
211
|
+
const selectedOptionId = method.groups?.[0]?.selected_option_id;
|
|
212
|
+
if (selectedOptionId) {
|
|
213
|
+
state.selectedOptionId = selectedOptionId;
|
|
214
|
+
} else if (selectedOptionId === null) {
|
|
215
|
+
delete state.selectedOptionId;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
async function resolveBuyerEmail(context, state) {
|
|
220
|
+
const email = state.buyer?.email ?? await context.getIdentityEmail();
|
|
221
|
+
if (!email && context.anonymousOrderEmail) {
|
|
222
|
+
console.warn(
|
|
223
|
+
`UCP: checkout session ${state.id} has no buyer email; using the configured anonymousOrderEmail \u2014 an order placed this way cannot send the buyer a receipt.`
|
|
224
|
+
);
|
|
225
|
+
return context.anonymousOrderEmail;
|
|
226
|
+
}
|
|
227
|
+
return email;
|
|
228
|
+
}
|
|
229
|
+
async function priceWithTransientCheckout(context, cart, state) {
|
|
230
|
+
const address = state.destination ?? state.billingAddress;
|
|
231
|
+
const checkoutCapability = context.client.checkout;
|
|
232
|
+
if (!address || !checkoutCapability) {
|
|
233
|
+
return { options: [] };
|
|
234
|
+
}
|
|
235
|
+
const initiated = await checkoutCapability.initiateCheckoutForCart({
|
|
236
|
+
cart,
|
|
237
|
+
billingAddress: toReactionaryAddress(state.billingAddress ?? address),
|
|
238
|
+
notificationEmail: await resolveBuyerEmail(context, state) ?? context.placeholderEmail,
|
|
239
|
+
...state.buyer?.phone_number ? { notificationPhone: state.buyer.phone_number } : {}
|
|
240
|
+
});
|
|
241
|
+
if (!initiated.success) {
|
|
242
|
+
return {
|
|
243
|
+
options: [],
|
|
244
|
+
failure: createUcpErrorMessage("checkout_pricing_failed", "Unable to price the checkout for the given address.")
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
let checkout = initiated.value;
|
|
248
|
+
try {
|
|
249
|
+
checkout = await applyFulfillment(context, checkout, state);
|
|
250
|
+
const options = await getShippingOptions(context, checkout);
|
|
251
|
+
return { checkout, options };
|
|
252
|
+
} finally {
|
|
253
|
+
await discardTransientCheckout(context, cart, checkout);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async function applyFulfillment(context, checkout, state) {
|
|
257
|
+
const checkoutCapability = context.client.checkout;
|
|
258
|
+
let current = checkout;
|
|
259
|
+
if (state.destination && checkoutCapability?.setShippingAddress) {
|
|
260
|
+
const result = await checkoutCapability.setShippingAddress({
|
|
261
|
+
checkout: current.identifier,
|
|
262
|
+
shippingAddress: toReactionaryAddress(state.destination)
|
|
263
|
+
});
|
|
264
|
+
if (result.success) {
|
|
265
|
+
current = result.value;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (state.selectedOptionId && checkoutCapability?.setShippingInstruction) {
|
|
269
|
+
const result = await checkoutCapability.setShippingInstruction({
|
|
270
|
+
checkout: current.identifier,
|
|
271
|
+
shippingInstruction: {
|
|
272
|
+
shippingMethod: { key: state.selectedOptionId },
|
|
273
|
+
instructions: "",
|
|
274
|
+
pickupPoint: "",
|
|
275
|
+
consentForUnattendedDelivery: false
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
if (result.success) {
|
|
279
|
+
current = result.value;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return current;
|
|
283
|
+
}
|
|
284
|
+
async function getShippingOptions(context, checkout) {
|
|
285
|
+
const checkoutCapability = context.client.checkout;
|
|
286
|
+
if (!checkoutCapability?.getAvailableShippingMethods) {
|
|
287
|
+
return [];
|
|
288
|
+
}
|
|
289
|
+
const result = await checkoutCapability.getAvailableShippingMethods({ checkout: checkout.identifier });
|
|
290
|
+
return result.success ? result.value : [];
|
|
291
|
+
}
|
|
292
|
+
async function discardTransientCheckout(context, cart, checkout) {
|
|
293
|
+
if (checkout.identifier.key === cart.identifier.key || !context.client.cart) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
await context.client.cart.deleteCart({ cart: checkout.identifier });
|
|
297
|
+
}
|
|
298
|
+
async function buildOpenView(context, state, warnings = []) {
|
|
299
|
+
const cart = await getCart(context, state.cartId);
|
|
300
|
+
if (!("identifier" in cart)) {
|
|
301
|
+
return { response: cart, ready: false };
|
|
302
|
+
}
|
|
303
|
+
const pricing = state.status === "canceled" ? { options: [] } : await priceWithTransientCheckout(context, cart, state);
|
|
304
|
+
const price = pricing.checkout?.price ?? cart.price;
|
|
305
|
+
const messages = [
|
|
306
|
+
...await getStockMessages(context, cart),
|
|
307
|
+
...getMissingInputMessages(context, state, pricing, await resolveBuyerEmail(context, state))
|
|
308
|
+
];
|
|
309
|
+
const ready = state.status === "open" && messages.length === 0;
|
|
310
|
+
messages.push(...warnings);
|
|
311
|
+
state.lastTotal = getMoneyValue(price.grandTotal);
|
|
312
|
+
const selectedOptionTitle = state.selectedOptionId ? getOptionTitles(pricing.options).get(state.selectedOptionId) : void 0;
|
|
313
|
+
if (selectedOptionTitle) {
|
|
314
|
+
state.selectedOptionTitle = selectedOptionTitle;
|
|
315
|
+
}
|
|
316
|
+
await context.store.putCheckoutSession(state);
|
|
317
|
+
const response = {
|
|
318
|
+
id: state.id,
|
|
319
|
+
status: state.status === "canceled" ? "canceled" : ready ? "ready_for_complete" : "incomplete",
|
|
320
|
+
line_items: cart.items.map(toUcpCartLineItem),
|
|
321
|
+
currency: getMoneyCurrency(price.grandTotal),
|
|
322
|
+
totals: toUcpCostTotals(price, pricing.checkout?.items ?? cart.items),
|
|
323
|
+
links: [],
|
|
324
|
+
...state.buyer ? { buyer: state.buyer } : {},
|
|
325
|
+
...toUcpPayment(state),
|
|
326
|
+
...toUcpFulfillment(context, cart, state, pricing.options),
|
|
327
|
+
...context.client.cart?.applyCouponCode ? toUcpDiscounts(cart) : {},
|
|
328
|
+
...messages.length > 0 ? { messages } : {},
|
|
329
|
+
ucp: createUcpCheckoutSuccessMetadata(context.paymentHandlers)
|
|
330
|
+
};
|
|
331
|
+
return { response, ready };
|
|
332
|
+
}
|
|
333
|
+
async function getStockMessages(context, cart) {
|
|
334
|
+
const inventoryCapability = context.client.inventory;
|
|
335
|
+
const fulfillmentCenterKeys = context.inventory?.fulfillmentCenterKeys ?? [];
|
|
336
|
+
if (!inventoryCapability || fulfillmentCenterKeys.length === 0) {
|
|
337
|
+
return [];
|
|
338
|
+
}
|
|
339
|
+
const messages = await Promise.all(cart.items.map(async (item, index) => {
|
|
340
|
+
const results = await Promise.all(fulfillmentCenterKeys.map((key) => inventoryCapability.getBySKU({
|
|
341
|
+
variant: item.variant,
|
|
342
|
+
fulfilmentCenter: { key }
|
|
343
|
+
})));
|
|
344
|
+
const inventories = results.flatMap((result) => result.success ? [result.value] : []);
|
|
345
|
+
if (inventories.length === 0 || inventories.some((inventory) => inventory.status === "onBackOrder" || inventory.status === "preOrder")) {
|
|
346
|
+
return void 0;
|
|
347
|
+
}
|
|
348
|
+
const available = inventories.reduce(
|
|
349
|
+
(total, inventory) => total + (inventory.status === "inStock" ? Math.max(inventory.quantity, 0) : 0),
|
|
350
|
+
0
|
|
351
|
+
);
|
|
352
|
+
if (item.quantity <= available) {
|
|
353
|
+
return void 0;
|
|
354
|
+
}
|
|
355
|
+
const sku = item.variant.sku;
|
|
356
|
+
return createUcpErrorMessage(
|
|
357
|
+
"out_of_stock",
|
|
358
|
+
available > 0 ? `Only ${available} of ${sku} are in stock.` : `${sku} is out of stock.`,
|
|
359
|
+
`$.line_items[${index}]`
|
|
360
|
+
);
|
|
361
|
+
}));
|
|
362
|
+
return messages.filter((message) => message !== void 0);
|
|
363
|
+
}
|
|
364
|
+
function getMissingInputMessages(context, state, pricing, email) {
|
|
365
|
+
const messages = pricing.failure ? [pricing.failure] : [];
|
|
366
|
+
if (!email) {
|
|
367
|
+
messages.push(createUcpErrorMessage("missing", "A buyer email is required to complete the checkout.", "$.buyer.email"));
|
|
368
|
+
}
|
|
369
|
+
if (context.client.checkout?.setShippingAddress) {
|
|
370
|
+
if (!state.destination) {
|
|
371
|
+
messages.push(createUcpErrorMessage("missing", "A shipping destination is required.", "$.fulfillment.methods[0].destinations"));
|
|
372
|
+
}
|
|
373
|
+
} else if (!state.destination && !state.billingAddress) {
|
|
374
|
+
messages.push(createUcpErrorMessage("missing", "A shipping destination or billing address is required.", "$.fulfillment.methods[0].destinations"));
|
|
375
|
+
}
|
|
376
|
+
if (pricing.options.length > 0 && !pricing.options.some((option) => option.identifier.key === state.selectedOptionId)) {
|
|
377
|
+
messages.push(createUcpErrorMessage("missing", "A fulfillment option must be selected.", "$.fulfillment.methods[0].groups[0].selected_option_id"));
|
|
378
|
+
}
|
|
379
|
+
if (!state.instrument) {
|
|
380
|
+
messages.push(createUcpErrorMessage("missing", "A payment instrument must be selected.", "$.payment.instruments"));
|
|
381
|
+
} else if (!isAcceptedPaymentHandler(context, state.instrument.handler_id)) {
|
|
382
|
+
messages.push(createUcpErrorMessage(
|
|
383
|
+
"payment_failed",
|
|
384
|
+
`Payment handler '${state.instrument.handler_id}' is not supported by this business.`,
|
|
385
|
+
"$.payment.instruments"
|
|
386
|
+
));
|
|
387
|
+
}
|
|
388
|
+
return messages;
|
|
389
|
+
}
|
|
390
|
+
function toUcpPayment(state) {
|
|
391
|
+
const candidate = {
|
|
392
|
+
instruments: state.instrument ? [{
|
|
393
|
+
id: state.instrument.id,
|
|
394
|
+
handler_id: state.instrument.handler_id,
|
|
395
|
+
type: state.instrument.type,
|
|
396
|
+
selected: true,
|
|
397
|
+
...state.billingAddress ? { billing_address: state.billingAddress } : {}
|
|
398
|
+
}] : []
|
|
399
|
+
};
|
|
400
|
+
return isUcpPayment(candidate) ? { payment: candidate } : {};
|
|
401
|
+
}
|
|
402
|
+
function isUcpPayment(value) {
|
|
403
|
+
const instruments = typeof value === "object" && value !== null ? Reflect.get(value, "instruments") : void 0;
|
|
404
|
+
return Array.isArray(instruments) && instruments.every(
|
|
405
|
+
(instrument) => typeof instrument === "object" && instrument !== null && typeof Reflect.get(instrument, "id") === "string" && typeof Reflect.get(instrument, "handler_id") === "string"
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
function toUcpFulfillment(context, cart, state, options) {
|
|
409
|
+
const destination = state.destination;
|
|
410
|
+
if (!context.client.checkout?.getAvailableShippingMethods) {
|
|
411
|
+
return {};
|
|
412
|
+
}
|
|
413
|
+
const lineItemIds = cart.items.map((item) => item.identifier.key);
|
|
414
|
+
const destinationId = destination?.id ?? "destination_1";
|
|
415
|
+
const titles = getOptionTitles(options);
|
|
416
|
+
const selectedOptionId = options.some((option) => option.identifier.key === state.selectedOptionId) ? state.selectedOptionId : void 0;
|
|
417
|
+
return {
|
|
418
|
+
fulfillment: {
|
|
419
|
+
methods: [
|
|
420
|
+
{
|
|
421
|
+
id: "shipping",
|
|
422
|
+
type: "shipping",
|
|
423
|
+
line_item_ids: lineItemIds,
|
|
424
|
+
destinations: destination ? [{ ...destination, id: destinationId }] : [],
|
|
425
|
+
...destination ? { selected_destination_id: destinationId } : {},
|
|
426
|
+
groups: [
|
|
427
|
+
{
|
|
428
|
+
id: "group_1",
|
|
429
|
+
line_item_ids: lineItemIds,
|
|
430
|
+
options: options.map((option) => ({
|
|
431
|
+
id: option.identifier.key,
|
|
432
|
+
title: titles.get(option.identifier.key) ?? option.identifier.key,
|
|
433
|
+
totals: [{ type: "total", amount: getMoneyValue(option.price) }]
|
|
434
|
+
})),
|
|
435
|
+
...selectedOptionId ? { selected_option_id: selectedOptionId } : {}
|
|
436
|
+
}
|
|
437
|
+
]
|
|
438
|
+
}
|
|
439
|
+
]
|
|
440
|
+
}
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
function getOptionTitles(options) {
|
|
444
|
+
const titles = /* @__PURE__ */ new Map();
|
|
445
|
+
const used = /* @__PURE__ */ new Set();
|
|
446
|
+
for (const option of options) {
|
|
447
|
+
let title = option.name || option.identifier.key;
|
|
448
|
+
if (used.has(title)) {
|
|
449
|
+
title = `${title} (${option.identifier.key})`;
|
|
450
|
+
}
|
|
451
|
+
used.add(title);
|
|
452
|
+
titles.set(option.identifier.key, title);
|
|
453
|
+
}
|
|
454
|
+
return titles;
|
|
455
|
+
}
|
|
456
|
+
async function placeFinalCheckout(context, state, payment) {
|
|
457
|
+
const checkoutCapability = context.client.checkout;
|
|
458
|
+
const address = state.destination ?? state.billingAddress;
|
|
459
|
+
const email = await resolveBuyerEmail(context, state);
|
|
460
|
+
const instrument = state.instrument;
|
|
461
|
+
const cart = await getCart(context, state.cartId);
|
|
462
|
+
if (!checkoutCapability || !address || !email || !instrument || !("identifier" in cart)) {
|
|
463
|
+
return {
|
|
464
|
+
failure: createUcpErrorMessage("checkout_complete_failed", "The checkout session is missing data required to complete it."),
|
|
465
|
+
messages: []
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const initiated = await checkoutCapability.initiateCheckoutForCart({
|
|
469
|
+
cart,
|
|
470
|
+
billingAddress: toReactionaryAddress(state.billingAddress ?? address),
|
|
471
|
+
notificationEmail: email,
|
|
472
|
+
...state.buyer?.phone_number ? { notificationPhone: state.buyer.phone_number } : {}
|
|
473
|
+
});
|
|
474
|
+
if (!initiated.success) {
|
|
475
|
+
return {
|
|
476
|
+
failure: createUcpErrorMessage("checkout_complete_failed", "The checkout could not be created."),
|
|
477
|
+
messages: []
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const checkout = await applyFulfillment(context, initiated.value, state);
|
|
481
|
+
const paid = await checkoutCapability.addPaymentInstruction({
|
|
482
|
+
checkout: checkout.identifier,
|
|
483
|
+
paymentInstruction: {
|
|
484
|
+
amount: checkout.price.grandTotal,
|
|
485
|
+
paymentMethod: {
|
|
486
|
+
method: instrument.type,
|
|
487
|
+
name: instrument.id,
|
|
488
|
+
paymentProcessor: payment.handlerId
|
|
489
|
+
},
|
|
490
|
+
protocolData: [
|
|
491
|
+
{ key: "ucp_payment_instrument_id", value: instrument.id },
|
|
492
|
+
{ key: "ucp_payment_handler_id", value: payment.handlerId },
|
|
493
|
+
{ key: "ucp_payment_instrument_type", value: instrument.type },
|
|
494
|
+
// Passed verbatim: the credential's shape is defined by the payment
|
|
495
|
+
// handler, and the backend's payment integration interprets it.
|
|
496
|
+
...payment.credential ? [{ key: "ucp_payment_credential", value: JSON.stringify(payment.credential) }] : []
|
|
497
|
+
]
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
if (!paid.success) {
|
|
501
|
+
return {
|
|
502
|
+
failure: createUcpErrorMessage("payment_failed", "The payment instruction could not be added.", "$.payment.instruments"),
|
|
503
|
+
messages: []
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const messages = [];
|
|
507
|
+
const finalTotal = getMoneyValue(paid.value.price.grandTotal);
|
|
508
|
+
if (state.lastTotal !== void 0 && state.lastTotal !== finalTotal) {
|
|
509
|
+
messages.push(createUcpWarning("price_changed", "The total changed since the checkout session was last shown.", "$.totals"));
|
|
510
|
+
}
|
|
511
|
+
state.finalCheckoutId = paid.value.identifier.key;
|
|
512
|
+
state.status = "complete_in_progress";
|
|
513
|
+
await context.store.putCheckoutSession(state);
|
|
514
|
+
return { messages };
|
|
515
|
+
}
|
|
516
|
+
async function finalizeIfReady(context, state, messages) {
|
|
517
|
+
const checkoutCapability = context.client.checkout;
|
|
518
|
+
if (state.status !== "completed" && checkoutCapability && state.finalCheckoutId) {
|
|
519
|
+
const finalCheckoutId = state.finalCheckoutId;
|
|
520
|
+
const current = await pollUntil(
|
|
521
|
+
() => checkoutCapability.getById({ identifier: { key: finalCheckoutId } }),
|
|
522
|
+
(result) => !result.success || Boolean(result.value.resultingOrder || result.value.readyForFinalization),
|
|
523
|
+
context.paymentAuthorizationWait
|
|
524
|
+
);
|
|
525
|
+
if (current.success && current.value.resultingOrder) {
|
|
526
|
+
state.status = "completed";
|
|
527
|
+
state.orderId = current.value.resultingOrder.key;
|
|
528
|
+
} else if (current.success && current.value.readyForFinalization) {
|
|
529
|
+
const finalized = await checkoutCapability.finalizeCheckout({ checkout: current.value.identifier });
|
|
530
|
+
if (finalized.success) {
|
|
531
|
+
state.status = "completed";
|
|
532
|
+
state.orderId = finalized.value.resultingOrder?.key;
|
|
533
|
+
} else {
|
|
534
|
+
messages.push(createUcpErrorMessage("checkout_complete_failed", "Checkout completion failed."));
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
await context.store.putCheckoutSession(state);
|
|
538
|
+
await recordOrder(context, state);
|
|
539
|
+
}
|
|
540
|
+
return buildFinalView(context, state, messages);
|
|
541
|
+
}
|
|
542
|
+
async function recordOrder(context, state) {
|
|
543
|
+
if (state.status !== "completed" || !state.orderId || await context.store.getOrder(state.orderId)) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
await context.store.putOrder({
|
|
547
|
+
id: state.orderId,
|
|
548
|
+
checkoutSessionId: state.id,
|
|
549
|
+
sessionId: context.sessionId,
|
|
550
|
+
...context.agentProfile ? { agentProfile: context.agentProfile } : {},
|
|
551
|
+
...state.destination ? { destination: state.destination } : {},
|
|
552
|
+
...state.selectedOptionTitle ? { fulfillmentTitle: state.selectedOptionTitle } : {},
|
|
553
|
+
events: [],
|
|
554
|
+
adjustments: []
|
|
555
|
+
});
|
|
556
|
+
context.onOrderPlaced?.(state.orderId);
|
|
557
|
+
}
|
|
558
|
+
async function buildFinalView(context, state, messages = []) {
|
|
559
|
+
const result = state.finalCheckoutId ? await context.client.checkout?.getById({ identifier: { key: state.finalCheckoutId } }) : void 0;
|
|
560
|
+
if (!result?.success) {
|
|
561
|
+
return createUCPError("not_found", `Checkout was not found for session: ${state.id}`);
|
|
562
|
+
}
|
|
563
|
+
const checkout = result.value;
|
|
564
|
+
const orderId = state.orderId ?? checkout.resultingOrder?.key;
|
|
565
|
+
return {
|
|
566
|
+
id: state.id,
|
|
567
|
+
status: state.status === "completed" ? "completed" : "complete_in_progress",
|
|
568
|
+
line_items: checkout.items.map(toUcpCartLineItem),
|
|
569
|
+
currency: getMoneyCurrency(checkout.price.grandTotal),
|
|
570
|
+
totals: toUcpCostTotals(checkout.price, checkout.items),
|
|
571
|
+
links: [],
|
|
572
|
+
...state.buyer ? { buyer: state.buyer } : {},
|
|
573
|
+
...orderId ? { order: { id: orderId, permalink_url: toOrderPermalinkUrl(context.merchantUrl, orderId) } } : {},
|
|
574
|
+
...messages.length > 0 ? { messages } : {},
|
|
575
|
+
ucp: createUcpCheckoutSuccessMetadata(context.paymentHandlers)
|
|
576
|
+
};
|
|
577
|
+
}
|
|
578
|
+
async function getCart(context, cartId) {
|
|
579
|
+
const result = await context.client.cart?.getById({ cart: { key: cartId } });
|
|
580
|
+
return result?.success ? result.value : createUCPError("not_found", `Cart was not found: ${cartId}`);
|
|
581
|
+
}
|
|
582
|
+
function addMessages(response, messages, status) {
|
|
583
|
+
if (!("id" in response)) {
|
|
584
|
+
return response;
|
|
585
|
+
}
|
|
586
|
+
return {
|
|
587
|
+
...response,
|
|
588
|
+
status,
|
|
589
|
+
messages: [...response.messages ?? [], ...messages]
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
function checkoutSessionNotFound(checkoutSessionId) {
|
|
593
|
+
return createUCPError("not_found", `Checkout session was not found: ${checkoutSessionId}`);
|
|
594
|
+
}
|
|
595
|
+
export {
|
|
596
|
+
DEFAULT_UCP_PAYMENT_AUTHORIZATION_WAIT,
|
|
597
|
+
DEFAULT_UCP_PLACEHOLDER_EMAIL,
|
|
598
|
+
cancelCheckoutSession,
|
|
599
|
+
completeCheckoutSession,
|
|
600
|
+
createCheckoutSession,
|
|
601
|
+
getCheckoutSession,
|
|
602
|
+
updateCheckoutSession
|
|
603
|
+
};
|