@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.
@@ -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
  ]),
@@ -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 = getOrCreateSessionId(request);
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
- checkoutId: checkout.identifier.key,
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, checkout, client, requestContext),
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
- let checkout = await this.getReactionaryCheckout(state, client);
289
- let cartId = state.cartId;
290
- if (input.items) {
291
- const cart = await this.createCartForItems(input.items, client);
292
- checkout = await unwrapACPResult(
293
- client.checkout.initiateCheckoutForCart({
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, checkout, client),
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
- let checkout = await this.getReactionaryCheckout(state, client);
367
- checkout = await unwrapACPResult(
368
- client.checkout.addPaymentInstruction({
369
- checkout: checkout.identifier,
370
- paymentInstruction: {
371
- amount: checkout.price.grandTotal,
372
- paymentMethod: toPaymentMethodIdentifier(input.payment_data.provider),
373
- protocolData: [
374
- {
375
- key: "delegated_payment_token",
376
- value: input.payment_data.token
377
- }
378
- ]
379
- }
380
- })
381
- );
382
- checkout = await unwrapACPResult(
383
- client.checkout.finalizeCheckout({
384
- checkout: checkout.identifier
385
- })
386
- );
387
- const completedState = {
388
- ...state,
389
- status: "completed",
390
- buyer: input.buyer ?? state.buyer,
391
- orderId: checkout.resultingOrder?.key
392
- };
393
- await this.checkoutSessionStore.put(checkoutSessionId, completedState);
394
- return jsonResponse(
395
- await this.toACPCheckoutSession(completedState, checkout, client)
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(canceledState, checkout, client)
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 getReactionaryCheckout(state, client) {
442
- return unwrapACPResult(
443
- client.checkout.getById({
444
- 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
445
427
  })
446
428
  );
447
- }
448
- async toACPCheckoutSession(state, checkout, client, requestContext = createInitialRequestContext()) {
449
- const fulfillmentOptions = await this.getFulfillmentOptions(
450
- checkout,
451
- 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
+ })
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: state.id,
455
- ...state.buyer ? { buyer: state.buyer } : {},
456
- payment_provider: await this.getPaymentProvider(checkout, client),
457
- status: state.status === "completed" || state.status === "canceled" ? state.status : getCheckoutStatus(checkout),
458
- currency: getCurrency(checkout, requestContext),
459
- line_items: checkout.items.map(toACPLineItem),
460
- ...state.fulfillmentAddress ? { fulfillment_address: state.fulfillmentAddress } : {},
461
- fulfillment_options: fulfillmentOptions,
462
- ...state.fulfillmentOptionId ? { fulfillment_option_id: state.fulfillmentOptionId } : checkout.shippingInstruction?.shippingMethod.key ? {
463
- fulfillment_option_id: checkout.shippingInstruction.shippingMethod.key
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: state.orderId,
469
- checkout_session_id: state.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 getFulfillmentOptions(checkout, client) {
478
- const result = await client.checkout.getAvailableShippingMethods({
479
- checkout: checkout.identifier
480
- });
481
- if (!result.success) {
482
- return [];
483
- }
484
- 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
+ };
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.success) {
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 getCheckoutStatus(checkout) {
684
- return checkout.readyForFinalization ? "ready_for_payment" : "not_ready_for_payment";
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(checkout) {
707
- const base = toMinorUnits(checkout.price.totalProductPrice.value);
708
- 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);
709
780
  const subtotal = Math.max(base - discount, 0);
710
- const fulfillment = toMinorUnits(checkout.price.totalShipping.value);
711
- const tax = toMinorUnits(checkout.price.totalTax.value);
712
- 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);
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.35",
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.35",
13
- "@reactionary/feeds": "0.10.35",
12
+ "@reactionary/core": "0.10.37",
13
+ "@reactionary/feeds": "0.10.37",
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[];
@@ -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 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;
123
160
  private toACPCheckoutSession;
124
- private getFulfillmentOptions;
161
+ private getPlacedView;
125
162
  private getPaymentProvider;
126
163
  }