@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.
@@ -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
- checkoutId: z.string(),
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
- const sessionId = getOrCreateSessionId(request);
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
- checkoutId: checkout.identifier.key,
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, checkout, client, requestContext),
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
- let checkout = await this.getReactionaryCheckout(state, client);
276
- let cartId = state.cartId;
277
- if (input.items) {
278
- const cart = await this.createCartForItems(input.items, client);
279
- checkout = await unwrapACPResult(
280
- client.checkout.initiateCheckoutForCart({
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, checkout, client),
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
- let checkout = await this.getReactionaryCheckout(state, client);
354
- checkout = await unwrapACPResult(
355
- client.checkout.addPaymentInstruction({
356
- checkout: checkout.identifier,
357
- paymentInstruction: {
358
- amount: checkout.price.grandTotal,
359
- paymentMethod: toPaymentMethodIdentifier(input.payment_data.provider),
360
- protocolData: [
361
- {
362
- key: "delegated_payment_token",
363
- value: input.payment_data.token
364
- }
365
- ]
366
- }
367
- })
368
- );
369
- checkout = await unwrapACPResult(
370
- client.checkout.finalizeCheckout({
371
- checkout: checkout.identifier
372
- })
373
- );
374
- const completedState = {
375
- ...state,
376
- status: "completed",
377
- buyer: input.buyer ?? state.buyer,
378
- orderId: checkout.resultingOrder?.key
379
- };
380
- await this.checkoutSessionStore.put(checkoutSessionId, completedState);
381
- return jsonResponse(
382
- await this.toACPCheckoutSession(completedState, checkout, client)
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(canceledState, checkout, client)
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 getReactionaryCheckout(state, client) {
429
- return unwrapACPResult(
430
- client.checkout.getById({
431
- identifier: { key: state.checkoutId }
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
- async toACPCheckoutSession(state, checkout, client, requestContext = createInitialRequestContext()) {
436
- const fulfillmentOptions = await this.getFulfillmentOptions(
437
- checkout,
438
- client
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: state.id,
442
- ...state.buyer ? { buyer: state.buyer } : {},
443
- payment_provider: await this.getPaymentProvider(checkout, client),
444
- status: state.status === "completed" || state.status === "canceled" ? state.status : getCheckoutStatus(checkout),
445
- currency: getCurrency(checkout, requestContext),
446
- line_items: checkout.items.map(toACPLineItem),
447
- ...state.fulfillmentAddress ? { fulfillment_address: state.fulfillmentAddress } : {},
448
- fulfillment_options: fulfillmentOptions,
449
- ...state.fulfillmentOptionId ? { fulfillment_option_id: state.fulfillmentOptionId } : checkout.shippingInstruction?.shippingMethod.key ? {
450
- fulfillment_option_id: checkout.shippingInstruction.shippingMethod.key
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: state.orderId,
456
- checkout_session_id: state.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 getFulfillmentOptions(checkout, client) {
465
- const result = await client.checkout.getAvailableShippingMethods({
466
- checkout: checkout.identifier
467
- });
468
- if (!result.success) {
469
- return [];
470
- }
471
- return result.value.map(toACPFulfillmentOption);
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.success) {
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 getCheckoutStatus(checkout) {
671
- return checkout.readyForFinalization ? "ready_for_payment" : "not_ready_for_payment";
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(checkout) {
694
- const base = toMinorUnits(checkout.price.totalProductPrice.value);
695
- const discount = toMinorUnits(checkout.price.totalDiscount.value);
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(checkout.price.totalShipping.value);
698
- const tax = toMinorUnits(checkout.price.totalTax.value);
699
- const total = toMinorUnits(checkout.price.grandTotal.value);
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.34",
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.34",
13
- "@reactionary/feeds": "0.10.34",
12
+ "@reactionary/core": "0.10.36",
13
+ "@reactionary/feeds": "0.10.36",
14
14
  "zod": "4.1.9"
15
15
  },
16
16
  "devDependencies": {
@@ -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
- completed: "completed";
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 getReactionaryCheckout;
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 getFulfillmentOptions;
161
+ private getPlacedView;
124
162
  private getPaymentProvider;
125
163
  }