@reactionary/acp 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/lib/acp-schemas.js
CHANGED
|
@@ -40,11 +40,15 @@ const ACPCompleteCheckoutSessionRequestSchema = z.object({
|
|
|
40
40
|
});
|
|
41
41
|
const ACPCheckoutSessionStateSchema = z.looseObject({
|
|
42
42
|
id: z.string(),
|
|
43
|
+
// The ACP session owning the backend cart, resumed for later requests.
|
|
44
|
+
sessionId: z.string().optional(),
|
|
43
45
|
cartId: z.string(),
|
|
44
|
-
|
|
46
|
+
// Set once completion has created the real reactionary checkout.
|
|
47
|
+
checkoutId: z.string().optional(),
|
|
45
48
|
status: z.enum([
|
|
46
49
|
"not_ready_for_payment",
|
|
47
50
|
"ready_for_payment",
|
|
51
|
+
"in_progress",
|
|
48
52
|
"completed",
|
|
49
53
|
"canceled"
|
|
50
54
|
]),
|
|
@@ -18,6 +18,11 @@ import {
|
|
|
18
18
|
const ACP_SESSION_ID_HEADER = "acp-session-id";
|
|
19
19
|
const SESSION_CACHE_KEY_PREFIX = "reactionary:acp:session";
|
|
20
20
|
const CHECKOUT_SESSION_CACHE_KEY_PREFIX = "reactionary:acp:checkout-session";
|
|
21
|
+
const DEFAULT_ACP_PLACEHOLDER_EMAIL = "pending@checkout.invalid";
|
|
22
|
+
const DEFAULT_ACP_PAYMENT_AUTHORIZATION_WAIT = {
|
|
23
|
+
timeoutMs: 1e4,
|
|
24
|
+
intervalMs: 1e3
|
|
25
|
+
};
|
|
21
26
|
const ACP_DISCOVERY_PATHS = ["/.well-known/acp.json", "/.well-known/acp"];
|
|
22
27
|
const ACP_DEFAULT_API_VERSION = "2026-01-30";
|
|
23
28
|
class ReactionaryACPServer {
|
|
@@ -50,7 +55,7 @@ class ReactionaryACPServer {
|
|
|
50
55
|
);
|
|
51
56
|
}
|
|
52
57
|
async handleFetch(request) {
|
|
53
|
-
const sessionId =
|
|
58
|
+
const sessionId = await this.resolveSessionId(request);
|
|
54
59
|
const requestContext = await this.createRequestContext(sessionId);
|
|
55
60
|
const requestedFeed = this.getRequestedProductFeed(request);
|
|
56
61
|
if (requestedFeed) {
|
|
@@ -58,11 +63,21 @@ class ReactionaryACPServer {
|
|
|
58
63
|
}
|
|
59
64
|
const client = this.clientFactory(requestContext);
|
|
60
65
|
assertACPClient(client);
|
|
61
|
-
const response = await this.handleRequest(request, client, requestContext).catch((error) => toACPErrorResponse(error));
|
|
66
|
+
const response = await this.handleRequest(request, client, requestContext, sessionId).catch((error) => toACPErrorResponse(error));
|
|
62
67
|
await this.sessionStore.put(sessionId, requestContext.session);
|
|
63
68
|
response.headers.set(ACP_SESSION_ID_HEADER, sessionId);
|
|
64
69
|
return response;
|
|
65
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Requests addressing a checkout session resume the session that created
|
|
73
|
+
* it: agents need not echo the ACP session header, but backends scope carts
|
|
74
|
+
* to that session's (anonymous) identity.
|
|
75
|
+
*/
|
|
76
|
+
async resolveSessionId(request) {
|
|
77
|
+
const checkoutSessionId = getCheckoutSessionId(request, this.options.basePath);
|
|
78
|
+
const state = checkoutSessionId ? await this.checkoutSessionStore.get(checkoutSessionId) : void 0;
|
|
79
|
+
return state?.sessionId ?? getOrCreateSessionId(request);
|
|
80
|
+
}
|
|
66
81
|
getHandler() {
|
|
67
82
|
return {
|
|
68
83
|
fetch: (request) => this.fetch(request),
|
|
@@ -86,7 +101,7 @@ class ReactionaryACPServer {
|
|
|
86
101
|
}
|
|
87
102
|
return requestContext;
|
|
88
103
|
}
|
|
89
|
-
async handleRequest(request, client, requestContext) {
|
|
104
|
+
async handleRequest(request, client, requestContext, sessionId) {
|
|
90
105
|
if (request.method === "OPTIONS") {
|
|
91
106
|
return new Response(null, {
|
|
92
107
|
status: 204,
|
|
@@ -137,7 +152,7 @@ class ReactionaryACPServer {
|
|
|
137
152
|
});
|
|
138
153
|
}
|
|
139
154
|
if (request.method === "POST") {
|
|
140
|
-
return this.handlePost(request, client, requestContext);
|
|
155
|
+
return this.handlePost(request, client, requestContext, sessionId);
|
|
141
156
|
}
|
|
142
157
|
return jsonResponse({
|
|
143
158
|
error: {
|
|
@@ -230,7 +245,7 @@ class ReactionaryACPServer {
|
|
|
230
245
|
getProductFeedDefinition(feedId) {
|
|
231
246
|
return this.options.productFeed?.feeds[feedId];
|
|
232
247
|
}
|
|
233
|
-
async handlePost(request, client, requestContext) {
|
|
248
|
+
async handlePost(request, client, requestContext, sessionId) {
|
|
234
249
|
const checkoutSessionId = getCheckoutSessionId(
|
|
235
250
|
request,
|
|
236
251
|
this.options.basePath
|
|
@@ -239,7 +254,8 @@ class ReactionaryACPServer {
|
|
|
239
254
|
return this.createCheckoutSession(
|
|
240
255
|
await parseJsonBody(request, ACPCreateCheckoutSessionRequestSchema),
|
|
241
256
|
client,
|
|
242
|
-
requestContext
|
|
257
|
+
requestContext,
|
|
258
|
+
sessionId
|
|
243
259
|
);
|
|
244
260
|
}
|
|
245
261
|
if (isCheckoutSessionCompleteRequest(request, this.options.basePath)) {
|
|
@@ -259,84 +275,39 @@ class ReactionaryACPServer {
|
|
|
259
275
|
requestContext
|
|
260
276
|
);
|
|
261
277
|
}
|
|
262
|
-
async createCheckoutSession(input, client, requestContext) {
|
|
278
|
+
async createCheckoutSession(input, client, requestContext, sessionId) {
|
|
263
279
|
const cart = await this.createCartForItems(input.items, client);
|
|
264
|
-
const checkout = await unwrapACPResult(
|
|
265
|
-
client.checkout.initiateCheckoutForCart({
|
|
266
|
-
cart,
|
|
267
|
-
billingAddress: input.fulfillment_address ? toReactionaryAddress(input.fulfillment_address) : void 0,
|
|
268
|
-
notificationEmail: input.buyer?.email,
|
|
269
|
-
notificationPhone: input.buyer?.phone_number
|
|
270
|
-
})
|
|
271
|
-
);
|
|
272
280
|
const state = {
|
|
273
281
|
id: `checkout_session_${crypto.randomUUID()}`,
|
|
282
|
+
sessionId,
|
|
274
283
|
cartId: cart.identifier.key,
|
|
275
|
-
|
|
276
|
-
status: getCheckoutStatus(checkout),
|
|
284
|
+
status: "not_ready_for_payment",
|
|
277
285
|
buyer: input.buyer,
|
|
278
286
|
fulfillmentAddress: input.fulfillment_address
|
|
279
287
|
};
|
|
280
|
-
await this.checkoutSessionStore.put(state.id, state);
|
|
281
288
|
return jsonResponse(
|
|
282
|
-
await this.toACPCheckoutSession(state,
|
|
289
|
+
await this.toACPCheckoutSession(state, client, requestContext),
|
|
283
290
|
{ status: 201 }
|
|
284
291
|
);
|
|
285
292
|
}
|
|
286
293
|
async updateCheckoutSession(checkoutSessionId, input, client, requestContext) {
|
|
287
294
|
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
cart,
|
|
295
|
-
billingAddress: input.fulfillment_address ? toReactionaryAddress(input.fulfillment_address) : state.fulfillmentAddress ? toReactionaryAddress(state.fulfillmentAddress) : void 0,
|
|
296
|
-
notificationEmail: input.buyer?.email ?? state.buyer?.email,
|
|
297
|
-
notificationPhone: input.buyer?.phone_number ?? state.buyer?.phone_number
|
|
298
|
-
})
|
|
299
|
-
);
|
|
300
|
-
cartId = cart.identifier.key;
|
|
301
|
-
}
|
|
302
|
-
if (input.fulfillment_address) {
|
|
303
|
-
checkout = await unwrapACPResult(
|
|
304
|
-
client.checkout.setShippingAddress({
|
|
305
|
-
checkout: checkout.identifier,
|
|
306
|
-
shippingAddress: toReactionaryAddress(input.fulfillment_address)
|
|
307
|
-
})
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
if (input.fulfillment_option_id) {
|
|
311
|
-
checkout = await unwrapACPResult(
|
|
312
|
-
client.checkout.setShippingInstruction({
|
|
313
|
-
checkout: checkout.identifier,
|
|
314
|
-
shippingInstruction: {
|
|
315
|
-
shippingMethod: { key: input.fulfillment_option_id },
|
|
316
|
-
pickupPoint: "",
|
|
317
|
-
instructions: "",
|
|
318
|
-
consentForUnattendedDelivery: false
|
|
319
|
-
}
|
|
320
|
-
})
|
|
321
|
-
);
|
|
295
|
+
if (state.checkoutId || state.status === "canceled") {
|
|
296
|
+
return acpErrorResponse(405, {
|
|
297
|
+
type: "invalid_request",
|
|
298
|
+
code: "invalid",
|
|
299
|
+
message: "The checkout session can no longer be modified."
|
|
300
|
+
});
|
|
322
301
|
}
|
|
323
302
|
const updatedState = {
|
|
324
303
|
...state,
|
|
325
|
-
cartId,
|
|
326
|
-
checkoutId: checkout.identifier.key,
|
|
327
|
-
status: getCheckoutStatus(checkout),
|
|
304
|
+
cartId: input.items ? (await this.createCartForItems(input.items, client)).identifier.key : state.cartId,
|
|
328
305
|
buyer: input.buyer ?? state.buyer,
|
|
329
306
|
fulfillmentAddress: input.fulfillment_address ?? state.fulfillmentAddress,
|
|
330
307
|
fulfillmentOptionId: input.fulfillment_option_id ?? state.fulfillmentOptionId
|
|
331
308
|
};
|
|
332
|
-
await this.checkoutSessionStore.put(checkoutSessionId, updatedState);
|
|
333
309
|
return jsonResponse(
|
|
334
|
-
await this.toACPCheckoutSession(
|
|
335
|
-
updatedState,
|
|
336
|
-
checkout,
|
|
337
|
-
client,
|
|
338
|
-
requestContext
|
|
339
|
-
)
|
|
310
|
+
await this.toACPCheckoutSession(updatedState, client, requestContext)
|
|
340
311
|
);
|
|
341
312
|
}
|
|
342
313
|
async getCheckoutSession(checkoutSessionId, client, omitBody) {
|
|
@@ -348,12 +319,16 @@ class ReactionaryACPServer {
|
|
|
348
319
|
message: `Checkout session not found: ${checkoutSessionId}`
|
|
349
320
|
});
|
|
350
321
|
}
|
|
351
|
-
const checkout = await this.getReactionaryCheckout(state, client);
|
|
352
322
|
return jsonResponse(
|
|
353
|
-
await this.toACPCheckoutSession(state,
|
|
323
|
+
await this.toACPCheckoutSession(state, client),
|
|
354
324
|
{ omitBody }
|
|
355
325
|
);
|
|
356
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Creates the real checkout from the session state and finalizes it. A
|
|
329
|
+
* payment the PSP has not authorized yet leaves the session `in_progress`;
|
|
330
|
+
* a repeated complete retries finalization.
|
|
331
|
+
*/
|
|
357
332
|
async completeCheckoutSession(checkoutSessionId, input, client) {
|
|
358
333
|
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
359
334
|
if (state.status === "canceled") {
|
|
@@ -363,55 +338,58 @@ class ReactionaryACPServer {
|
|
|
363
338
|
message: "Canceled checkout sessions cannot be completed."
|
|
364
339
|
});
|
|
365
340
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
341
|
+
const buyer = input.buyer ?? state.buyer;
|
|
342
|
+
let current = { ...state, buyer };
|
|
343
|
+
if (!current.checkoutId) {
|
|
344
|
+
const view = await this.priceSession(current, client);
|
|
345
|
+
if (view.status !== "ready_for_payment") {
|
|
346
|
+
return acpErrorResponse(400, {
|
|
347
|
+
type: "invalid_request",
|
|
348
|
+
code: "invalid",
|
|
349
|
+
message: "The checkout session is missing the buyer email, fulfillment address or fulfillment option."
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
const checkout = await this.placeCheckout(current, input, client);
|
|
353
|
+
current = {
|
|
354
|
+
...current,
|
|
355
|
+
checkoutId: checkout.identifier.key,
|
|
356
|
+
status: "in_progress"
|
|
357
|
+
};
|
|
358
|
+
await this.checkoutSessionStore.put(current.id, current);
|
|
359
|
+
}
|
|
360
|
+
if (current.status !== "completed" && current.checkoutId) {
|
|
361
|
+
const checkoutId = current.checkoutId;
|
|
362
|
+
const wait = {
|
|
363
|
+
...DEFAULT_ACP_PAYMENT_AUTHORIZATION_WAIT,
|
|
364
|
+
...this.options.paymentAuthorizationWait
|
|
365
|
+
};
|
|
366
|
+
const checkout = await pollUntil(
|
|
367
|
+
() => unwrapACPResult(client.checkout.getById({ identifier: { key: checkoutId } })),
|
|
368
|
+
(candidate) => Boolean(candidate.resultingOrder || candidate.readyForFinalization),
|
|
369
|
+
wait
|
|
370
|
+
);
|
|
371
|
+
if (checkout.resultingOrder) {
|
|
372
|
+
current = { ...current, status: "completed", orderId: checkout.resultingOrder.key };
|
|
373
|
+
} else if (checkout.readyForFinalization) {
|
|
374
|
+
const finalized = await unwrapACPResult(
|
|
375
|
+
client.checkout.finalizeCheckout({ checkout: checkout.identifier })
|
|
376
|
+
);
|
|
377
|
+
current = { ...current, status: "completed", orderId: finalized.resultingOrder?.key };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return jsonResponse(await this.toACPCheckoutSession(current, client));
|
|
397
381
|
}
|
|
398
382
|
async cancelCheckoutSession(checkoutSessionId, client) {
|
|
399
383
|
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
400
|
-
if (state.status === "completed" || state.status === "canceled") {
|
|
384
|
+
if (state.checkoutId || state.status === "completed" || state.status === "canceled") {
|
|
401
385
|
return acpErrorResponse(405, {
|
|
402
386
|
type: "invalid_request",
|
|
403
387
|
code: "invalid",
|
|
404
388
|
message: "Completed or canceled checkout sessions cannot be canceled."
|
|
405
389
|
});
|
|
406
390
|
}
|
|
407
|
-
const canceledState = {
|
|
408
|
-
...state,
|
|
409
|
-
status: "canceled"
|
|
410
|
-
};
|
|
411
|
-
const checkout = await this.getReactionaryCheckout(canceledState, client);
|
|
412
|
-
await this.checkoutSessionStore.put(checkoutSessionId, canceledState);
|
|
413
391
|
return jsonResponse(
|
|
414
|
-
await this.toACPCheckoutSession(
|
|
392
|
+
await this.toACPCheckoutSession({ ...state, status: "canceled" }, client)
|
|
415
393
|
);
|
|
416
394
|
}
|
|
417
395
|
async createCartForItems(items, client) {
|
|
@@ -438,35 +416,109 @@ class ReactionaryACPServer {
|
|
|
438
416
|
}
|
|
439
417
|
return state;
|
|
440
418
|
}
|
|
441
|
-
async
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
419
|
+
async placeCheckout(state, input, client) {
|
|
420
|
+
const cart = await unwrapACPResult(client.cart.getById({ cart: { key: state.cartId } }));
|
|
421
|
+
let checkout = await unwrapACPResult(
|
|
422
|
+
client.checkout.initiateCheckoutForCart({
|
|
423
|
+
cart,
|
|
424
|
+
billingAddress: state.fulfillmentAddress ? toReactionaryAddress(state.fulfillmentAddress) : void 0,
|
|
425
|
+
notificationEmail: state.buyer?.email,
|
|
426
|
+
notificationPhone: state.buyer?.phone_number
|
|
445
427
|
})
|
|
446
428
|
);
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
429
|
+
if (state.fulfillmentOptionId) {
|
|
430
|
+
checkout = await unwrapACPResult(
|
|
431
|
+
client.checkout.setShippingInstruction(toShippingInstruction(checkout, state.fulfillmentOptionId))
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
const paid = await client.checkout.addPaymentInstruction({
|
|
435
|
+
checkout: checkout.identifier,
|
|
436
|
+
paymentInstruction: {
|
|
437
|
+
amount: checkout.price.grandTotal,
|
|
438
|
+
paymentMethod: toPaymentMethodIdentifier(input.payment_data.provider),
|
|
439
|
+
protocolData: [
|
|
440
|
+
{
|
|
441
|
+
key: "delegated_payment_token",
|
|
442
|
+
value: input.payment_data.token
|
|
443
|
+
},
|
|
444
|
+
{
|
|
445
|
+
key: "delegated_payment_provider",
|
|
446
|
+
value: input.payment_data.provider
|
|
447
|
+
}
|
|
448
|
+
]
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
if (!paid.success) {
|
|
452
|
+
throw new ACPHttpError(400, {
|
|
453
|
+
type: "processing_error",
|
|
454
|
+
code: "payment_declined",
|
|
455
|
+
message: "The payment could not be authorized with the delegated payment token."
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
return paid.value;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Prices the open session with a transient checkout, which is discarded
|
|
462
|
+
* again: a reactionary checkout is a frozen snapshot, while an ACP session
|
|
463
|
+
* keeps changing until completion. Backends need an email to quote, so a
|
|
464
|
+
* placeholder stands in until the buyer supplies one.
|
|
465
|
+
*/
|
|
466
|
+
async priceSession(state, client) {
|
|
467
|
+
const cart = await unwrapACPResult(client.cart.getById({ cart: { key: state.cartId } }));
|
|
468
|
+
if (!state.fulfillmentAddress || state.status === "canceled") {
|
|
469
|
+
return { cart, price: cart.price, options: [], status: state.status === "canceled" ? "canceled" : "not_ready_for_payment" };
|
|
470
|
+
}
|
|
471
|
+
let checkout = await unwrapACPResult(
|
|
472
|
+
client.checkout.initiateCheckoutForCart({
|
|
473
|
+
cart,
|
|
474
|
+
billingAddress: toReactionaryAddress(state.fulfillmentAddress),
|
|
475
|
+
notificationEmail: state.buyer?.email ?? this.options.placeholderEmail ?? DEFAULT_ACP_PLACEHOLDER_EMAIL,
|
|
476
|
+
notificationPhone: state.buyer?.phone_number
|
|
477
|
+
})
|
|
452
478
|
);
|
|
479
|
+
try {
|
|
480
|
+
const shippingMethods = await client.checkout.getAvailableShippingMethods({ checkout: checkout.identifier });
|
|
481
|
+
const options = shippingMethods.success ? shippingMethods.value : [];
|
|
482
|
+
const selected = options.find((option) => option.identifier.key === state.fulfillmentOptionId);
|
|
483
|
+
if (selected) {
|
|
484
|
+
const withShipping = await client.checkout.setShippingInstruction(
|
|
485
|
+
toShippingInstruction(checkout, selected.identifier.key)
|
|
486
|
+
);
|
|
487
|
+
checkout = withShipping.success ? withShipping.value : checkout;
|
|
488
|
+
}
|
|
489
|
+
const ready = Boolean(state.buyer?.email) && (options.length === 0 || Boolean(selected));
|
|
490
|
+
return {
|
|
491
|
+
cart,
|
|
492
|
+
checkout,
|
|
493
|
+
price: checkout.price,
|
|
494
|
+
options,
|
|
495
|
+
status: ready ? "ready_for_payment" : "not_ready_for_payment"
|
|
496
|
+
};
|
|
497
|
+
} finally {
|
|
498
|
+
if (checkout.identifier.key !== cart.identifier.key && client.cart.deleteCart) {
|
|
499
|
+
await client.cart.deleteCart({ cart: checkout.identifier });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async toACPCheckoutSession(state, client, requestContext = createInitialRequestContext()) {
|
|
504
|
+
const view = state.checkoutId ? await this.getPlacedView(state, client) : await this.priceSession(state, client);
|
|
505
|
+
const persisted = { ...state, status: view.status };
|
|
506
|
+
await this.checkoutSessionStore.put(persisted.id, persisted);
|
|
453
507
|
return {
|
|
454
|
-
id:
|
|
455
|
-
...
|
|
456
|
-
payment_provider: await this.getPaymentProvider(checkout, client),
|
|
457
|
-
status:
|
|
458
|
-
currency: getCurrency(
|
|
459
|
-
line_items: checkout.items.map(toACPLineItem),
|
|
460
|
-
...
|
|
461
|
-
fulfillment_options:
|
|
462
|
-
...
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
totals: toACPTotals(checkout),
|
|
466
|
-
...state.orderId ? {
|
|
508
|
+
id: persisted.id,
|
|
509
|
+
...persisted.buyer ? { buyer: persisted.buyer } : {},
|
|
510
|
+
payment_provider: await this.getPaymentProvider(view.checkout, client),
|
|
511
|
+
status: persisted.status,
|
|
512
|
+
currency: getCurrency(view.price, requestContext),
|
|
513
|
+
line_items: (view.checkout?.items ?? view.cart?.items ?? []).map(toACPLineItem),
|
|
514
|
+
...persisted.fulfillmentAddress ? { fulfillment_address: persisted.fulfillmentAddress } : {},
|
|
515
|
+
fulfillment_options: view.options.map(toACPFulfillmentOption),
|
|
516
|
+
...persisted.fulfillmentOptionId ? { fulfillment_option_id: persisted.fulfillmentOptionId } : {},
|
|
517
|
+
totals: toACPTotals(view.price),
|
|
518
|
+
...persisted.orderId ? {
|
|
467
519
|
order: {
|
|
468
|
-
id:
|
|
469
|
-
checkout_session_id:
|
|
520
|
+
id: persisted.orderId,
|
|
521
|
+
checkout_session_id: persisted.id,
|
|
470
522
|
permalink_url: ""
|
|
471
523
|
}
|
|
472
524
|
} : {},
|
|
@@ -474,23 +526,25 @@ class ReactionaryACPServer {
|
|
|
474
526
|
links: this.options.links ?? []
|
|
475
527
|
};
|
|
476
528
|
}
|
|
477
|
-
async
|
|
478
|
-
const
|
|
479
|
-
checkout:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
529
|
+
async getPlacedView(state, client) {
|
|
530
|
+
const checkout = await unwrapACPResult(
|
|
531
|
+
client.checkout.getById({ identifier: { key: state.checkoutId ?? "" } })
|
|
532
|
+
);
|
|
533
|
+
return {
|
|
534
|
+
checkout,
|
|
535
|
+
price: checkout.price,
|
|
536
|
+
options: [],
|
|
537
|
+
status: state.status === "completed" || checkout.resultingOrder ? "completed" : "in_progress"
|
|
538
|
+
};
|
|
485
539
|
}
|
|
486
540
|
async getPaymentProvider(checkout, client) {
|
|
487
541
|
if (this.options.paymentProvider) {
|
|
488
542
|
return this.options.paymentProvider;
|
|
489
543
|
}
|
|
490
|
-
const result = await client.checkout.getAvailablePaymentMethods({
|
|
544
|
+
const result = checkout ? await client.checkout.getAvailablePaymentMethods({
|
|
491
545
|
checkout: checkout.identifier
|
|
492
|
-
});
|
|
493
|
-
if (result
|
|
546
|
+
}) : void 0;
|
|
547
|
+
if (result?.success) {
|
|
494
548
|
const supported = result.value.find(
|
|
495
549
|
(method) => ["stripe", "adyen", "braintree"].includes(
|
|
496
550
|
method.identifier.paymentProcessor
|
|
@@ -509,6 +563,26 @@ class ReactionaryACPServer {
|
|
|
509
563
|
};
|
|
510
564
|
}
|
|
511
565
|
}
|
|
566
|
+
function toShippingInstruction(checkout, fulfillmentOptionId) {
|
|
567
|
+
return {
|
|
568
|
+
checkout: checkout.identifier,
|
|
569
|
+
shippingInstruction: {
|
|
570
|
+
shippingMethod: { key: fulfillmentOptionId },
|
|
571
|
+
pickupPoint: "",
|
|
572
|
+
instructions: "",
|
|
573
|
+
consentForUnattendedDelivery: false
|
|
574
|
+
}
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
async function pollUntil(read, done, wait) {
|
|
578
|
+
const deadline = Date.now() + wait.timeoutMs;
|
|
579
|
+
let value = await read();
|
|
580
|
+
while (!done(value) && Date.now() < deadline) {
|
|
581
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(wait.intervalMs, Math.max(deadline - Date.now(), 0))));
|
|
582
|
+
value = await read();
|
|
583
|
+
}
|
|
584
|
+
return value;
|
|
585
|
+
}
|
|
512
586
|
class ACPHttpError extends Error {
|
|
513
587
|
constructor(status, body) {
|
|
514
588
|
super(body.message);
|
|
@@ -680,11 +754,8 @@ async function unwrapACPResult(resultPromise) {
|
|
|
680
754
|
}
|
|
681
755
|
return result.value;
|
|
682
756
|
}
|
|
683
|
-
function
|
|
684
|
-
return
|
|
685
|
-
}
|
|
686
|
-
function getCurrency(checkout, requestContext) {
|
|
687
|
-
return (checkout.price.grandTotal.currency ?? requestContext.languageContext.currencyCode).toLowerCase();
|
|
757
|
+
function getCurrency(price, requestContext) {
|
|
758
|
+
return (price.grandTotal.currency ?? requestContext.languageContext.currencyCode).toLowerCase();
|
|
688
759
|
}
|
|
689
760
|
function toACPLineItem(item) {
|
|
690
761
|
const baseAmount = toMinorUnits(item.price.unitPrice.value * item.quantity);
|
|
@@ -703,13 +774,13 @@ function toACPLineItem(item) {
|
|
|
703
774
|
total
|
|
704
775
|
};
|
|
705
776
|
}
|
|
706
|
-
function toACPTotals(
|
|
707
|
-
const base = toMinorUnits(
|
|
708
|
-
const discount = toMinorUnits(
|
|
777
|
+
function toACPTotals(price) {
|
|
778
|
+
const base = toMinorUnits(price.totalProductPrice.value);
|
|
779
|
+
const discount = toMinorUnits(price.totalDiscount.value);
|
|
709
780
|
const subtotal = Math.max(base - discount, 0);
|
|
710
|
-
const fulfillment = toMinorUnits(
|
|
711
|
-
const tax = toMinorUnits(
|
|
712
|
-
const total = toMinorUnits(
|
|
781
|
+
const fulfillment = toMinorUnits(price.totalShipping.value);
|
|
782
|
+
const tax = toMinorUnits(price.totalTax.value);
|
|
783
|
+
const total = toMinorUnits(price.grandTotal.value);
|
|
713
784
|
return [
|
|
714
785
|
{
|
|
715
786
|
type: "items_base_amount",
|
|
@@ -858,5 +929,7 @@ function getAcpOperationPath(request, basePath) {
|
|
|
858
929
|
return pathname.replace(/^\/checkout_sessions\/[^/]+/, "/checkout_sessions/{id}").replace(/^\/product_feeds\/[^/]+/, "/product_feeds/{id}");
|
|
859
930
|
}
|
|
860
931
|
export {
|
|
932
|
+
DEFAULT_ACP_PAYMENT_AUTHORIZATION_WAIT,
|
|
933
|
+
DEFAULT_ACP_PLACEHOLDER_EMAIL,
|
|
861
934
|
ReactionaryACPServer
|
|
862
935
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reactionary/acp",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.37",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./index.js",
|
|
6
6
|
"types": "./src/index.d.ts",
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
"url": "git+https://github.com/Solteq/reactionary.git"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"@reactionary/core": "0.10.
|
|
13
|
-
"@reactionary/feeds": "0.10.
|
|
12
|
+
"@reactionary/core": "0.10.37",
|
|
13
|
+
"@reactionary/feeds": "0.10.37",
|
|
14
14
|
"zod": "4.1.9"
|
|
15
15
|
},
|
|
16
16
|
"devDependencies": {
|
package/src/lib/acp-schemas.d.ts
CHANGED
|
@@ -106,13 +106,15 @@ export declare const ACPCompleteCheckoutSessionRequestSchema: z.ZodObject<{
|
|
|
106
106
|
}, z.core.$strip>;
|
|
107
107
|
export declare const ACPCheckoutSessionStateSchema: z.ZodObject<{
|
|
108
108
|
id: z.ZodString;
|
|
109
|
+
sessionId: z.ZodOptional<z.ZodString>;
|
|
109
110
|
cartId: z.ZodString;
|
|
110
|
-
checkoutId: z.ZodString
|
|
111
|
+
checkoutId: z.ZodOptional<z.ZodString>;
|
|
111
112
|
status: z.ZodEnum<{
|
|
113
|
+
canceled: "canceled";
|
|
114
|
+
completed: "completed";
|
|
112
115
|
not_ready_for_payment: "not_ready_for_payment";
|
|
113
116
|
ready_for_payment: "ready_for_payment";
|
|
114
|
-
|
|
115
|
-
canceled: "canceled";
|
|
117
|
+
in_progress: "in_progress";
|
|
116
118
|
}>;
|
|
117
119
|
buyer: z.ZodOptional<z.ZodObject<{
|
|
118
120
|
name: z.ZodString;
|
|
@@ -6,6 +6,7 @@ export interface ReactionaryACPClient {
|
|
|
6
6
|
createCart(payload: unknown): Promise<Result<Cart>>;
|
|
7
7
|
add(payload: unknown): Promise<Result<Cart>>;
|
|
8
8
|
getById(payload: unknown): Promise<Result<Cart>>;
|
|
9
|
+
deleteCart?(payload: unknown): Promise<Result<void>>;
|
|
9
10
|
};
|
|
10
11
|
checkout?: {
|
|
11
12
|
initiateCheckoutForCart(payload: unknown): Promise<Result<Checkout>>;
|
|
@@ -43,7 +44,25 @@ export interface ReactionaryACPServerOptions {
|
|
|
43
44
|
links?: ACPLink[];
|
|
44
45
|
productFeed?: ACPProductFeedOptions;
|
|
45
46
|
discovery?: ACPDiscoveryOptions;
|
|
47
|
+
/**
|
|
48
|
+
* Email used to price transient checkouts before the buyer has supplied
|
|
49
|
+
* one. Never used for the real checkout created on completion.
|
|
50
|
+
*/
|
|
51
|
+
placeholderEmail?: string;
|
|
52
|
+
/**
|
|
53
|
+
* How long checkout completion waits for the placed checkout to become
|
|
54
|
+
* `readyForFinalization` (i.e. its payment authorized, e.g. by a PSP
|
|
55
|
+
* webhook) before answering `in_progress`. Defaults to 10s timeout,
|
|
56
|
+
* polled every 1s; a timeout of 0 disables it.
|
|
57
|
+
*/
|
|
58
|
+
paymentAuthorizationWait?: Partial<ACPPaymentAuthorizationWait>;
|
|
46
59
|
}
|
|
60
|
+
export declare const DEFAULT_ACP_PLACEHOLDER_EMAIL = "pending@checkout.invalid";
|
|
61
|
+
export interface ACPPaymentAuthorizationWait {
|
|
62
|
+
timeoutMs: number;
|
|
63
|
+
intervalMs: number;
|
|
64
|
+
}
|
|
65
|
+
export declare const DEFAULT_ACP_PAYMENT_AUTHORIZATION_WAIT: ACPPaymentAuthorizationWait;
|
|
47
66
|
export interface ACPDiscoveryOptions {
|
|
48
67
|
apiVersion?: string;
|
|
49
68
|
supportedVersions?: string[];
|
|
@@ -101,6 +120,12 @@ export declare class ReactionaryACPServer<TClient extends ReactionaryACPClient =
|
|
|
101
120
|
constructor(clientFactory: ReactionaryACPClientFactory<TClient>, options?: ReactionaryACPServerOptions);
|
|
102
121
|
fetch(request: Request): Promise<Response>;
|
|
103
122
|
private handleFetch;
|
|
123
|
+
/**
|
|
124
|
+
* Requests addressing a checkout session resume the session that created
|
|
125
|
+
* it: agents need not echo the ACP session header, but backends scope carts
|
|
126
|
+
* to that session's (anonymous) identity.
|
|
127
|
+
*/
|
|
128
|
+
private resolveSessionId;
|
|
104
129
|
getHandler(): ReactionaryACPHttpHandler;
|
|
105
130
|
toNodeHandler(): ReactionaryACPNodeRequestHandler;
|
|
106
131
|
close(): Promise<void>;
|
|
@@ -115,12 +140,24 @@ export declare class ReactionaryACPServer<TClient extends ReactionaryACPClient =
|
|
|
115
140
|
private createCheckoutSession;
|
|
116
141
|
private updateCheckoutSession;
|
|
117
142
|
private getCheckoutSession;
|
|
143
|
+
/**
|
|
144
|
+
* Creates the real checkout from the session state and finalizes it. A
|
|
145
|
+
* payment the PSP has not authorized yet leaves the session `in_progress`;
|
|
146
|
+
* a repeated complete retries finalization.
|
|
147
|
+
*/
|
|
118
148
|
private completeCheckoutSession;
|
|
119
149
|
private cancelCheckoutSession;
|
|
120
150
|
private createCartForItems;
|
|
121
151
|
private getRequiredCheckoutSessionState;
|
|
122
|
-
private
|
|
152
|
+
private placeCheckout;
|
|
153
|
+
/**
|
|
154
|
+
* Prices the open session with a transient checkout, which is discarded
|
|
155
|
+
* again: a reactionary checkout is a frozen snapshot, while an ACP session
|
|
156
|
+
* keeps changing until completion. Backends need an email to quote, so a
|
|
157
|
+
* placeholder stands in until the buyer supplies one.
|
|
158
|
+
*/
|
|
159
|
+
private priceSession;
|
|
123
160
|
private toACPCheckoutSession;
|
|
124
|
-
private
|
|
161
|
+
private getPlacedView;
|
|
125
162
|
private getPaymentProvider;
|
|
126
163
|
}
|