@reactionary/acp 0.10.34 → 0.10.36
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
|
]),
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createInitialRequestContext,
|
|
3
|
+
getHttpProtocolResultAttributes,
|
|
3
4
|
MemoryCache,
|
|
5
|
+
traceProtocolInvocation,
|
|
4
6
|
SessionSchema
|
|
5
7
|
} from "@reactionary/core";
|
|
6
8
|
import {
|
|
@@ -16,6 +18,11 @@ import {
|
|
|
16
18
|
const ACP_SESSION_ID_HEADER = "acp-session-id";
|
|
17
19
|
const SESSION_CACHE_KEY_PREFIX = "reactionary:acp:session";
|
|
18
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
|
+
};
|
|
19
26
|
const ACP_DISCOVERY_PATHS = ["/.well-known/acp.json", "/.well-known/acp"];
|
|
20
27
|
const ACP_DEFAULT_API_VERSION = "2026-01-30";
|
|
21
28
|
class ReactionaryACPServer {
|
|
@@ -37,7 +44,18 @@ class ReactionaryACPServer {
|
|
|
37
44
|
sessionStore;
|
|
38
45
|
checkoutSessionStore;
|
|
39
46
|
async fetch(request) {
|
|
40
|
-
|
|
47
|
+
return traceProtocolInvocation(
|
|
48
|
+
{
|
|
49
|
+
protocol: "acp",
|
|
50
|
+
operation: `${request.method} ${getAcpOperationPath(request, this.options.basePath)}`,
|
|
51
|
+
attributes: { "http.request.method": request.method }
|
|
52
|
+
},
|
|
53
|
+
() => this.handleFetch(request),
|
|
54
|
+
getHttpProtocolResultAttributes
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
async handleFetch(request) {
|
|
58
|
+
const sessionId = await this.resolveSessionId(request);
|
|
41
59
|
const requestContext = await this.createRequestContext(sessionId);
|
|
42
60
|
const requestedFeed = this.getRequestedProductFeed(request);
|
|
43
61
|
if (requestedFeed) {
|
|
@@ -45,11 +63,21 @@ class ReactionaryACPServer {
|
|
|
45
63
|
}
|
|
46
64
|
const client = this.clientFactory(requestContext);
|
|
47
65
|
assertACPClient(client);
|
|
48
|
-
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));
|
|
49
67
|
await this.sessionStore.put(sessionId, requestContext.session);
|
|
50
68
|
response.headers.set(ACP_SESSION_ID_HEADER, sessionId);
|
|
51
69
|
return response;
|
|
52
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
|
+
}
|
|
53
81
|
getHandler() {
|
|
54
82
|
return {
|
|
55
83
|
fetch: (request) => this.fetch(request),
|
|
@@ -73,7 +101,7 @@ class ReactionaryACPServer {
|
|
|
73
101
|
}
|
|
74
102
|
return requestContext;
|
|
75
103
|
}
|
|
76
|
-
async handleRequest(request, client, requestContext) {
|
|
104
|
+
async handleRequest(request, client, requestContext, sessionId) {
|
|
77
105
|
if (request.method === "OPTIONS") {
|
|
78
106
|
return new Response(null, {
|
|
79
107
|
status: 204,
|
|
@@ -124,7 +152,7 @@ class ReactionaryACPServer {
|
|
|
124
152
|
});
|
|
125
153
|
}
|
|
126
154
|
if (request.method === "POST") {
|
|
127
|
-
return this.handlePost(request, client, requestContext);
|
|
155
|
+
return this.handlePost(request, client, requestContext, sessionId);
|
|
128
156
|
}
|
|
129
157
|
return jsonResponse({
|
|
130
158
|
error: {
|
|
@@ -217,7 +245,7 @@ class ReactionaryACPServer {
|
|
|
217
245
|
getProductFeedDefinition(feedId) {
|
|
218
246
|
return this.options.productFeed?.feeds[feedId];
|
|
219
247
|
}
|
|
220
|
-
async handlePost(request, client, requestContext) {
|
|
248
|
+
async handlePost(request, client, requestContext, sessionId) {
|
|
221
249
|
const checkoutSessionId = getCheckoutSessionId(
|
|
222
250
|
request,
|
|
223
251
|
this.options.basePath
|
|
@@ -226,7 +254,8 @@ class ReactionaryACPServer {
|
|
|
226
254
|
return this.createCheckoutSession(
|
|
227
255
|
await parseJsonBody(request, ACPCreateCheckoutSessionRequestSchema),
|
|
228
256
|
client,
|
|
229
|
-
requestContext
|
|
257
|
+
requestContext,
|
|
258
|
+
sessionId
|
|
230
259
|
);
|
|
231
260
|
}
|
|
232
261
|
if (isCheckoutSessionCompleteRequest(request, this.options.basePath)) {
|
|
@@ -246,84 +275,39 @@ class ReactionaryACPServer {
|
|
|
246
275
|
requestContext
|
|
247
276
|
);
|
|
248
277
|
}
|
|
249
|
-
async createCheckoutSession(input, client, requestContext) {
|
|
278
|
+
async createCheckoutSession(input, client, requestContext, sessionId) {
|
|
250
279
|
const cart = await this.createCartForItems(input.items, client);
|
|
251
|
-
const checkout = await unwrapACPResult(
|
|
252
|
-
client.checkout.initiateCheckoutForCart({
|
|
253
|
-
cart,
|
|
254
|
-
billingAddress: input.fulfillment_address ? toReactionaryAddress(input.fulfillment_address) : void 0,
|
|
255
|
-
notificationEmail: input.buyer?.email,
|
|
256
|
-
notificationPhone: input.buyer?.phone_number
|
|
257
|
-
})
|
|
258
|
-
);
|
|
259
280
|
const state = {
|
|
260
281
|
id: `checkout_session_${crypto.randomUUID()}`,
|
|
282
|
+
sessionId,
|
|
261
283
|
cartId: cart.identifier.key,
|
|
262
|
-
|
|
263
|
-
status: getCheckoutStatus(checkout),
|
|
284
|
+
status: "not_ready_for_payment",
|
|
264
285
|
buyer: input.buyer,
|
|
265
286
|
fulfillmentAddress: input.fulfillment_address
|
|
266
287
|
};
|
|
267
|
-
await this.checkoutSessionStore.put(state.id, state);
|
|
268
288
|
return jsonResponse(
|
|
269
|
-
await this.toACPCheckoutSession(state,
|
|
289
|
+
await this.toACPCheckoutSession(state, client, requestContext),
|
|
270
290
|
{ status: 201 }
|
|
271
291
|
);
|
|
272
292
|
}
|
|
273
293
|
async updateCheckoutSession(checkoutSessionId, input, client, requestContext) {
|
|
274
294
|
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
cart,
|
|
282
|
-
billingAddress: input.fulfillment_address ? toReactionaryAddress(input.fulfillment_address) : state.fulfillmentAddress ? toReactionaryAddress(state.fulfillmentAddress) : void 0,
|
|
283
|
-
notificationEmail: input.buyer?.email ?? state.buyer?.email,
|
|
284
|
-
notificationPhone: input.buyer?.phone_number ?? state.buyer?.phone_number
|
|
285
|
-
})
|
|
286
|
-
);
|
|
287
|
-
cartId = cart.identifier.key;
|
|
288
|
-
}
|
|
289
|
-
if (input.fulfillment_address) {
|
|
290
|
-
checkout = await unwrapACPResult(
|
|
291
|
-
client.checkout.setShippingAddress({
|
|
292
|
-
checkout: checkout.identifier,
|
|
293
|
-
shippingAddress: toReactionaryAddress(input.fulfillment_address)
|
|
294
|
-
})
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
if (input.fulfillment_option_id) {
|
|
298
|
-
checkout = await unwrapACPResult(
|
|
299
|
-
client.checkout.setShippingInstruction({
|
|
300
|
-
checkout: checkout.identifier,
|
|
301
|
-
shippingInstruction: {
|
|
302
|
-
shippingMethod: { key: input.fulfillment_option_id },
|
|
303
|
-
pickupPoint: "",
|
|
304
|
-
instructions: "",
|
|
305
|
-
consentForUnattendedDelivery: false
|
|
306
|
-
}
|
|
307
|
-
})
|
|
308
|
-
);
|
|
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
|
+
});
|
|
309
301
|
}
|
|
310
302
|
const updatedState = {
|
|
311
303
|
...state,
|
|
312
|
-
cartId,
|
|
313
|
-
checkoutId: checkout.identifier.key,
|
|
314
|
-
status: getCheckoutStatus(checkout),
|
|
304
|
+
cartId: input.items ? (await this.createCartForItems(input.items, client)).identifier.key : state.cartId,
|
|
315
305
|
buyer: input.buyer ?? state.buyer,
|
|
316
306
|
fulfillmentAddress: input.fulfillment_address ?? state.fulfillmentAddress,
|
|
317
307
|
fulfillmentOptionId: input.fulfillment_option_id ?? state.fulfillmentOptionId
|
|
318
308
|
};
|
|
319
|
-
await this.checkoutSessionStore.put(checkoutSessionId, updatedState);
|
|
320
309
|
return jsonResponse(
|
|
321
|
-
await this.toACPCheckoutSession(
|
|
322
|
-
updatedState,
|
|
323
|
-
checkout,
|
|
324
|
-
client,
|
|
325
|
-
requestContext
|
|
326
|
-
)
|
|
310
|
+
await this.toACPCheckoutSession(updatedState, client, requestContext)
|
|
327
311
|
);
|
|
328
312
|
}
|
|
329
313
|
async getCheckoutSession(checkoutSessionId, client, omitBody) {
|
|
@@ -335,12 +319,16 @@ class ReactionaryACPServer {
|
|
|
335
319
|
message: `Checkout session not found: ${checkoutSessionId}`
|
|
336
320
|
});
|
|
337
321
|
}
|
|
338
|
-
const checkout = await this.getReactionaryCheckout(state, client);
|
|
339
322
|
return jsonResponse(
|
|
340
|
-
await this.toACPCheckoutSession(state,
|
|
323
|
+
await this.toACPCheckoutSession(state, client),
|
|
341
324
|
{ omitBody }
|
|
342
325
|
);
|
|
343
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
|
+
*/
|
|
344
332
|
async completeCheckoutSession(checkoutSessionId, input, client) {
|
|
345
333
|
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
346
334
|
if (state.status === "canceled") {
|
|
@@ -350,55 +338,58 @@ class ReactionaryACPServer {
|
|
|
350
338
|
message: "Canceled checkout sessions cannot be completed."
|
|
351
339
|
});
|
|
352
340
|
}
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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));
|
|
384
381
|
}
|
|
385
382
|
async cancelCheckoutSession(checkoutSessionId, client) {
|
|
386
383
|
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
387
|
-
if (state.status === "completed" || state.status === "canceled") {
|
|
384
|
+
if (state.checkoutId || state.status === "completed" || state.status === "canceled") {
|
|
388
385
|
return acpErrorResponse(405, {
|
|
389
386
|
type: "invalid_request",
|
|
390
387
|
code: "invalid",
|
|
391
388
|
message: "Completed or canceled checkout sessions cannot be canceled."
|
|
392
389
|
});
|
|
393
390
|
}
|
|
394
|
-
const canceledState = {
|
|
395
|
-
...state,
|
|
396
|
-
status: "canceled"
|
|
397
|
-
};
|
|
398
|
-
const checkout = await this.getReactionaryCheckout(canceledState, client);
|
|
399
|
-
await this.checkoutSessionStore.put(checkoutSessionId, canceledState);
|
|
400
391
|
return jsonResponse(
|
|
401
|
-
await this.toACPCheckoutSession(
|
|
392
|
+
await this.toACPCheckoutSession({ ...state, status: "canceled" }, client)
|
|
402
393
|
);
|
|
403
394
|
}
|
|
404
395
|
async createCartForItems(items, client) {
|
|
@@ -425,35 +416,109 @@ class ReactionaryACPServer {
|
|
|
425
416
|
}
|
|
426
417
|
return state;
|
|
427
418
|
}
|
|
428
|
-
async
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
|
432
427
|
})
|
|
433
428
|
);
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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
|
+
})
|
|
439
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);
|
|
440
507
|
return {
|
|
441
|
-
id:
|
|
442
|
-
...
|
|
443
|
-
payment_provider: await this.getPaymentProvider(checkout, client),
|
|
444
|
-
status:
|
|
445
|
-
currency: getCurrency(
|
|
446
|
-
line_items: checkout.items.map(toACPLineItem),
|
|
447
|
-
...
|
|
448
|
-
fulfillment_options:
|
|
449
|
-
...
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
totals: toACPTotals(checkout),
|
|
453
|
-
...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 ? {
|
|
454
519
|
order: {
|
|
455
|
-
id:
|
|
456
|
-
checkout_session_id:
|
|
520
|
+
id: persisted.orderId,
|
|
521
|
+
checkout_session_id: persisted.id,
|
|
457
522
|
permalink_url: ""
|
|
458
523
|
}
|
|
459
524
|
} : {},
|
|
@@ -461,23 +526,25 @@ class ReactionaryACPServer {
|
|
|
461
526
|
links: this.options.links ?? []
|
|
462
527
|
};
|
|
463
528
|
}
|
|
464
|
-
async
|
|
465
|
-
const
|
|
466
|
-
checkout:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
+
};
|
|
472
539
|
}
|
|
473
540
|
async getPaymentProvider(checkout, client) {
|
|
474
541
|
if (this.options.paymentProvider) {
|
|
475
542
|
return this.options.paymentProvider;
|
|
476
543
|
}
|
|
477
|
-
const result = await client.checkout.getAvailablePaymentMethods({
|
|
544
|
+
const result = checkout ? await client.checkout.getAvailablePaymentMethods({
|
|
478
545
|
checkout: checkout.identifier
|
|
479
|
-
});
|
|
480
|
-
if (result
|
|
546
|
+
}) : void 0;
|
|
547
|
+
if (result?.success) {
|
|
481
548
|
const supported = result.value.find(
|
|
482
549
|
(method) => ["stripe", "adyen", "braintree"].includes(
|
|
483
550
|
method.identifier.paymentProcessor
|
|
@@ -496,6 +563,26 @@ class ReactionaryACPServer {
|
|
|
496
563
|
};
|
|
497
564
|
}
|
|
498
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
|
+
}
|
|
499
586
|
class ACPHttpError extends Error {
|
|
500
587
|
constructor(status, body) {
|
|
501
588
|
super(body.message);
|
|
@@ -667,11 +754,8 @@ async function unwrapACPResult(resultPromise) {
|
|
|
667
754
|
}
|
|
668
755
|
return result.value;
|
|
669
756
|
}
|
|
670
|
-
function
|
|
671
|
-
return
|
|
672
|
-
}
|
|
673
|
-
function getCurrency(checkout, requestContext) {
|
|
674
|
-
return (checkout.price.grandTotal.currency ?? requestContext.languageContext.currencyCode).toLowerCase();
|
|
757
|
+
function getCurrency(price, requestContext) {
|
|
758
|
+
return (price.grandTotal.currency ?? requestContext.languageContext.currencyCode).toLowerCase();
|
|
675
759
|
}
|
|
676
760
|
function toACPLineItem(item) {
|
|
677
761
|
const baseAmount = toMinorUnits(item.price.unitPrice.value * item.quantity);
|
|
@@ -690,13 +774,13 @@ function toACPLineItem(item) {
|
|
|
690
774
|
total
|
|
691
775
|
};
|
|
692
776
|
}
|
|
693
|
-
function toACPTotals(
|
|
694
|
-
const base = toMinorUnits(
|
|
695
|
-
const discount = toMinorUnits(
|
|
777
|
+
function toACPTotals(price) {
|
|
778
|
+
const base = toMinorUnits(price.totalProductPrice.value);
|
|
779
|
+
const discount = toMinorUnits(price.totalDiscount.value);
|
|
696
780
|
const subtotal = Math.max(base - discount, 0);
|
|
697
|
-
const fulfillment = toMinorUnits(
|
|
698
|
-
const tax = toMinorUnits(
|
|
699
|
-
const total = toMinorUnits(
|
|
781
|
+
const fulfillment = toMinorUnits(price.totalShipping.value);
|
|
782
|
+
const tax = toMinorUnits(price.totalTax.value);
|
|
783
|
+
const total = toMinorUnits(price.grandTotal.value);
|
|
700
784
|
return [
|
|
701
785
|
{
|
|
702
786
|
type: "items_base_amount",
|
|
@@ -840,6 +924,12 @@ async function sendWebResponse(response, webResponse) {
|
|
|
840
924
|
}
|
|
841
925
|
response.end();
|
|
842
926
|
}
|
|
927
|
+
function getAcpOperationPath(request, basePath) {
|
|
928
|
+
const pathname = getProtocolPathname(request, basePath);
|
|
929
|
+
return pathname.replace(/^\/checkout_sessions\/[^/]+/, "/checkout_sessions/{id}").replace(/^\/product_feeds\/[^/]+/, "/product_feeds/{id}");
|
|
930
|
+
}
|
|
843
931
|
export {
|
|
932
|
+
DEFAULT_ACP_PAYMENT_AUTHORIZATION_WAIT,
|
|
933
|
+
DEFAULT_ACP_PLACEHOLDER_EMAIL,
|
|
844
934
|
ReactionaryACPServer
|
|
845
935
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reactionary/acp",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.36",
|
|
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.36",
|
|
13
|
+
"@reactionary/feeds": "0.10.36",
|
|
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[];
|
|
@@ -100,6 +119,13 @@ export declare class ReactionaryACPServer<TClient extends ReactionaryACPClient =
|
|
|
100
119
|
private readonly checkoutSessionStore;
|
|
101
120
|
constructor(clientFactory: ReactionaryACPClientFactory<TClient>, options?: ReactionaryACPServerOptions);
|
|
102
121
|
fetch(request: Request): Promise<Response>;
|
|
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;
|
|
103
129
|
getHandler(): ReactionaryACPHttpHandler;
|
|
104
130
|
toNodeHandler(): ReactionaryACPNodeRequestHandler;
|
|
105
131
|
close(): Promise<void>;
|
|
@@ -114,12 +140,24 @@ export declare class ReactionaryACPServer<TClient extends ReactionaryACPClient =
|
|
|
114
140
|
private createCheckoutSession;
|
|
115
141
|
private updateCheckoutSession;
|
|
116
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
|
+
*/
|
|
117
148
|
private completeCheckoutSession;
|
|
118
149
|
private cancelCheckoutSession;
|
|
119
150
|
private createCartForItems;
|
|
120
151
|
private getRequiredCheckoutSessionState;
|
|
121
|
-
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;
|
|
122
160
|
private toACPCheckoutSession;
|
|
123
|
-
private
|
|
161
|
+
private getPlacedView;
|
|
124
162
|
private getPaymentProvider;
|
|
125
163
|
}
|