@reactionary/acp 0.10.42 → 0.10.44

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.
@@ -1,30 +1,64 @@
1
1
  import {
2
+ CurrencySchema,
2
3
  createInitialRequestContext,
3
4
  getHttpProtocolResultAttributes,
4
5
  MemoryCache,
5
6
  traceProtocolInvocation,
6
7
  SessionSchema
7
8
  } from "@reactionary/core";
8
- import {
9
- ReactionaryFeedGenerator,
10
- acpProductFeedTransformer
11
- } from "@reactionary/feeds";
9
+ import { createHash, timingSafeEqual } from "node:crypto";
12
10
  import {
13
11
  ACPCheckoutSessionStateSchema,
14
12
  ACPCompleteCheckoutSessionRequestSchema,
15
13
  ACPCreateCheckoutSessionRequestSchema,
16
- ACPUpdateCheckoutSessionRequestSchema
14
+ ACPUpdateCheckoutSessionRequestSchema,
15
+ ACP_INTERVENTION_TYPES,
16
+ ACPOrderRecordSchema,
17
+ ACPAffiliateAttributionSchema,
18
+ ACPCancelCheckoutSessionRequestSchema,
19
+ ACPCartCreateRequestSchema,
20
+ ACPCartStateSchema,
21
+ ACPCartUpdateRequestSchema,
22
+ ACP_AUTHENTICATED_OUTCOMES
17
23
  } from "./acp-schemas.js";
24
+ import { ACPIdempotency, ACP_MIN_IDEMPOTENCY_TTL_SECONDS } from "./acp-idempotency.js";
25
+ import { ACPMcpEndpoint } from "./acp-mcp.js";
26
+ import {
27
+ ACP_DISCOUNT_EXTENSION,
28
+ applyDiscountCodes,
29
+ getRequestedDiscountCodes,
30
+ toACPDiscounts
31
+ } from "./acp-discounts.js";
32
+ import { ACPOrderWebhooks } from "./acp-webhooks.js";
33
+ import {
34
+ getHandlerPaymentMethod
35
+ } from "./acp-payment-handlers.js";
18
36
  const ACP_SESSION_ID_HEADER = "acp-session-id";
19
37
  const SESSION_CACHE_KEY_PREFIX = "reactionary:acp:session";
20
38
  const CHECKOUT_SESSION_CACHE_KEY_PREFIX = "reactionary:acp:checkout-session";
39
+ const ORDER_CACHE_KEY_PREFIX = "reactionary:acp:order";
40
+ const CART_CACHE_KEY_PREFIX = "reactionary:acp:cart";
41
+ function createBearerTokenAuthenticator(tokensByAgentId) {
42
+ const entries = Object.entries(tokensByAgentId);
43
+ return (request) => {
44
+ const match = /^Bearer\s+(.+)$/i.exec(request.headers.get("authorization") ?? "");
45
+ const token = match?.[1]?.trim();
46
+ if (!token) {
47
+ return void 0;
48
+ }
49
+ const agent = entries.find(([, candidate]) => secureEquals(candidate, token));
50
+ return agent ? { id: agent[0] } : void 0;
51
+ };
52
+ }
21
53
  const DEFAULT_ACP_PLACEHOLDER_EMAIL = "pending@checkout.invalid";
22
54
  const DEFAULT_ACP_PAYMENT_AUTHORIZATION_WAIT = {
23
55
  timeoutMs: 1e4,
24
56
  intervalMs: 1e3
25
57
  };
26
58
  const ACP_DISCOVERY_PATHS = ["/.well-known/acp.json", "/.well-known/acp"];
27
- const ACP_DEFAULT_API_VERSION = "2026-01-30";
59
+ const ACP_API_VERSION = "2026-04-17";
60
+ const ACP_SUPPORTED_API_VERSIONS = [ACP_API_VERSION];
61
+ const ACP_API_VERSION_HEADER = "api-version";
28
62
  class ReactionaryACPServer {
29
63
  constructor(clientFactory, options = {}) {
30
64
  this.clientFactory = clientFactory;
@@ -35,14 +69,45 @@ class ReactionaryACPServer {
35
69
  );
36
70
  this.checkoutSessionStore = new ReactionaryACPCheckoutSessionStore(
37
71
  this.options.sessionCache ?? new MemoryCache(),
38
- this.options.checkoutSessionTtlSeconds ?? 60 * 60 * 24
72
+ this.options.checkoutSessionTtlSeconds ?? 60 * 60 * 24,
73
+ this.options.orderTtlSeconds ?? 60 * 60 * 24 * 90
74
+ );
75
+ this.idempotency = new ACPIdempotency(
76
+ this.options.sessionCache ?? new MemoryCache(),
77
+ Math.max(this.options.idempotencyTtlSeconds ?? 0, ACP_MIN_IDEMPOTENCY_TTL_SECONDS)
39
78
  );
40
- assertACPClient(this.clientFactory(createInitialRequestContext()));
79
+ const client = this.clientFactory(createInitialRequestContext());
80
+ assertACPClient(client);
81
+ assertPaymentHandlers(this.options.paymentHandlers ?? []);
82
+ this.supportsDiscounts = Boolean(client.cart.applyCouponCode && client.cart.removeCouponCode);
83
+ this.mcp = this.options.mcp ? new ACPMcpEndpoint(
84
+ { name: this.options.name ?? "@reactionary/acp", version: this.options.version ?? "0.0.1" },
85
+ (restRequest) => this.fetch(restRequest)
86
+ ) : void 0;
87
+ this.webhooks = this.options.webhooks ? new ACPOrderWebhooks(this.options.webhooks) : void 0;
88
+ if (this.webhooks && !client.order?.getById) {
89
+ throw new Error("ACP order webhooks need the client operation order.getById.");
90
+ }
91
+ if (!this.webhooks) {
92
+ console.warn("ACP: order webhooks are disabled; configure `webhooks` (ACP requires order_create/order_update events).");
93
+ }
94
+ if (!this.options.orderPermalinkUrl) {
95
+ console.warn("ACP: orders are returned without permalink_url; configure `orderPermalinkUrl` (ACP requires a permalink).");
96
+ } else if (!this.options.orderPermalinkUrl.includes("{orderId}")) {
97
+ throw new Error("ACP orderPermalinkUrl must contain an {orderId} placeholder.");
98
+ }
99
+ if (!this.options.authenticate) {
100
+ console.warn("ACP: checkout endpoints are unauthenticated; configure `authenticate` (ACP requires agents to authenticate).");
101
+ }
41
102
  }
42
103
  clientFactory;
43
104
  options;
44
105
  sessionStore;
45
106
  checkoutSessionStore;
107
+ idempotency;
108
+ webhooks;
109
+ supportsDiscounts;
110
+ mcp;
46
111
  async fetch(request) {
47
112
  return traceProtocolInvocation(
48
113
  {
@@ -50,24 +115,76 @@ class ReactionaryACPServer {
50
115
  operation: `${request.method} ${getAcpOperationPath(request, this.options.basePath)}`,
51
116
  attributes: { "http.request.method": request.method }
52
117
  },
53
- () => this.handleFetch(request),
118
+ async () => echoRequestHeaders(request, await this.handleFetch(request)),
54
119
  getHttpProtocolResultAttributes
55
120
  );
56
121
  }
57
122
  async handleFetch(request) {
123
+ if (this.mcp && getProtocolPathname(request, this.options.basePath) === "/mcp") {
124
+ const basePath = (this.options.basePath ?? "/acp").replace(/\/$/, "");
125
+ return this.mcp.handle(request, `${new URL(request.url).origin}${basePath}`);
126
+ }
127
+ let agent;
128
+ try {
129
+ agent = await this.authenticate(request);
130
+ if (isCheckoutPost(request, this.options.basePath)) {
131
+ assertSupportedApiVersion(request);
132
+ }
133
+ } catch (error) {
134
+ return toACPErrorResponse(error);
135
+ }
58
136
  const sessionId = await this.resolveSessionId(request);
59
137
  const requestContext = await this.createRequestContext(sessionId);
60
- const requestedFeed = this.getRequestedProductFeed(request);
61
- if (requestedFeed) {
62
- requestContext.languageContext = requestedFeed.feed.languageContext;
138
+ const locale = this.resolveLocale(request);
139
+ if (locale) {
140
+ requestContext.languageContext = { ...requestContext.languageContext, locale };
141
+ }
142
+ const currency = await this.resolveCheckoutCurrency(request);
143
+ if (currency) {
144
+ requestContext.languageContext = {
145
+ ...requestContext.languageContext,
146
+ currencyCode: currency
147
+ };
63
148
  }
64
149
  const client = this.clientFactory(requestContext);
65
150
  assertACPClient(client);
66
- const response = await this.handleRequest(request, client, requestContext, sessionId).catch((error) => toACPErrorResponse(error));
151
+ const handle = () => this.handleRequest(request, client, requestContext, sessionId, agent).catch((error) => toACPErrorResponse(error));
152
+ const response = isCheckoutPost(request, this.options.basePath) ? await this.idempotency.run(request, agent?.id ?? "anonymous", handle) : await handle();
67
153
  await this.sessionStore.put(sessionId, requestContext.session);
68
154
  response.headers.set(ACP_SESSION_ID_HEADER, sessionId);
69
155
  return response;
70
156
  }
157
+ /**
158
+ * Checkout endpoints require an authenticated agent (checkout RFC §3.1),
159
+ * and a checkout session is visible only to the agent that created it, so
160
+ * another agent's session is reported as missing. Discovery and the
161
+ * readiness document stay public.
162
+ */
163
+ async authenticate(request) {
164
+ const pathname = getProtocolPathname(request, this.options.basePath);
165
+ const cartRoute = getCartRoute(request, this.options.basePath);
166
+ if (!this.options.authenticate || !pathname.startsWith("/checkout_sessions") && !cartRoute) {
167
+ return void 0;
168
+ }
169
+ const agent = await this.options.authenticate(request);
170
+ if (!agent) {
171
+ throw new ACPHttpError(401, {
172
+ type: "invalid_request",
173
+ code: "unauthorized",
174
+ message: "A valid Authorization bearer token is required."
175
+ }, { "www-authenticate": "Bearer" });
176
+ }
177
+ const checkoutSessionId = getCheckoutSessionId(request, this.options.basePath);
178
+ const state = checkoutSessionId ? await this.checkoutSessionStore.get(checkoutSessionId) : cartRoute?.cartId ? await this.checkoutSessionStore.getCart(cartRoute.cartId) : void 0;
179
+ if (state?.agentId !== void 0 && state.agentId !== agent.id) {
180
+ throw new ACPHttpError(404, {
181
+ type: "invalid_request",
182
+ code: checkoutSessionId ? "missing" : "not_found",
183
+ message: checkoutSessionId ? `Checkout session not found: ${checkoutSessionId}` : `Cart not found: ${cartRoute?.cartId}`
184
+ });
185
+ }
186
+ return agent;
187
+ }
71
188
  /**
72
189
  * Requests addressing a checkout session resume the session that created
73
190
  * it: agents need not echo the ACP session header, but backends scope carts
@@ -75,9 +192,61 @@ class ReactionaryACPServer {
75
192
  */
76
193
  async resolveSessionId(request) {
77
194
  const checkoutSessionId = getCheckoutSessionId(request, this.options.basePath);
78
- const state = checkoutSessionId ? await this.checkoutSessionStore.get(checkoutSessionId) : void 0;
195
+ const cartId = getCartRoute(request, this.options.basePath)?.cartId;
196
+ const state = checkoutSessionId ? await this.checkoutSessionStore.get(checkoutSessionId) : cartId ? await this.checkoutSessionStore.getCart(cartId) : void 0;
79
197
  return state?.sessionId ?? getOrCreateSessionId(request);
80
198
  }
199
+ /**
200
+ * The best supported locale for the request's Accept-Language: an exact
201
+ * tag, else one of the same language. Only locales advertised in
202
+ * `discovery.supportedLocales` are used, as backends may not localize
203
+ * others.
204
+ */
205
+ resolveLocale(request) {
206
+ const supported = this.options.discovery?.supportedLocales ?? [];
207
+ const requested = parseAcceptLanguage(request.headers.get("accept-language"));
208
+ for (const tag of requested) {
209
+ const exact = supported.find((locale) => locale.toLowerCase() === tag.toLowerCase());
210
+ const sameLanguage = supported.find(
211
+ (locale) => locale.split("-")[0]?.toLowerCase() === tag.split("-")[0]?.toLowerCase()
212
+ );
213
+ if (exact ?? sameLanguage) {
214
+ return exact ?? sameLanguage;
215
+ }
216
+ }
217
+ return void 0;
218
+ }
219
+ /**
220
+ * The currency a checkout session is priced in: the one requested on its
221
+ * creation, which later requests for the session keep using.
222
+ */
223
+ async resolveCheckoutCurrency(request) {
224
+ const checkoutSessionId = getCheckoutSessionId(request, this.options.basePath);
225
+ let currency;
226
+ if (checkoutSessionId) {
227
+ currency = (await this.checkoutSessionStore.get(checkoutSessionId))?.currency;
228
+ } else if (request.method === "POST" && getProtocolPathname(request, this.options.basePath) === "/checkout_sessions") {
229
+ const body = await request.clone().json().catch(() => void 0);
230
+ currency = typeof body === "object" && body !== null ? Reflect.get(body, "currency") : void 0;
231
+ }
232
+ const parsed = CurrencySchema.safeParse(typeof currency === "string" ? currency.toUpperCase() : void 0);
233
+ return parsed.success ? parsed.data : void 0;
234
+ }
235
+ /**
236
+ * Sends an `order_update` event with the order's current state, e.g. when
237
+ * the OMS ships or cancels it. Returns false for orders not placed through
238
+ * an ACP checkout session, or when webhooks are not configured.
239
+ */
240
+ async notifyOrderUpdated(orderId) {
241
+ const record = await this.checkoutSessionStore.getOrder(orderId);
242
+ if (!this.webhooks || !record) {
243
+ return false;
244
+ }
245
+ const requestContext = await this.createRequestContext(record.sessionId ?? crypto.randomUUID());
246
+ const client = this.clientFactory(requestContext);
247
+ assertACPClient(client);
248
+ return this.sendOrderEvent("order_update", record, client);
249
+ }
81
250
  getHandler() {
82
251
  return {
83
252
  fetch: (request) => this.fetch(request),
@@ -101,12 +270,12 @@ class ReactionaryACPServer {
101
270
  }
102
271
  return requestContext;
103
272
  }
104
- async handleRequest(request, client, requestContext, sessionId) {
273
+ async handleRequest(request, client, requestContext, sessionId, agent) {
105
274
  if (request.method === "OPTIONS") {
106
275
  return new Response(null, {
107
276
  status: 204,
108
277
  headers: {
109
- allow: "GET, HEAD, OPTIONS, POST"
278
+ allow: "GET, HEAD, OPTIONS, POST, PUT"
110
279
  }
111
280
  });
112
281
  }
@@ -116,31 +285,18 @@ class ReactionaryACPServer {
116
285
  omitBody: request.method === "HEAD"
117
286
  });
118
287
  }
288
+ const cartRoute = getCartRoute(request, this.options.basePath);
289
+ if (cartRoute) {
290
+ assertSupportedApiVersion(request);
291
+ return this.handleCartRequest(request, cartRoute, client, sessionId, agent);
292
+ }
119
293
  if (request.method === "GET" || request.method === "HEAD") {
120
- const productFeedId = getProductFeedId(request, this.options.basePath);
121
- if (productFeedId) {
122
- const feed = this.getProductFeedDefinition(productFeedId);
123
- if (!feed) {
124
- return acpErrorResponse(404, {
125
- type: "invalid_request",
126
- code: "missing",
127
- message: `Product feed not found: ${productFeedId}`
128
- });
129
- }
130
- return this.getProductFeed(
131
- request,
132
- productFeedId,
133
- feed,
134
- client,
135
- requestContext,
136
- request.method === "HEAD"
137
- );
138
- }
139
294
  const checkoutSessionId = getCheckoutSessionId(
140
295
  request,
141
296
  this.options.basePath
142
297
  );
143
298
  if (checkoutSessionId) {
299
+ assertSupportedApiVersion(request);
144
300
  return this.getCheckoutSession(
145
301
  checkoutSessionId,
146
302
  client,
@@ -152,7 +308,8 @@ class ReactionaryACPServer {
152
308
  });
153
309
  }
154
310
  if (request.method === "POST") {
155
- return this.handlePost(request, client, requestContext, sessionId);
311
+ assertSupportedApiVersion(request);
312
+ return this.handlePost(request, client, requestContext, sessionId, agent);
156
313
  }
157
314
  return jsonResponse({
158
315
  error: {
@@ -162,32 +319,108 @@ class ReactionaryACPServer {
162
319
  }, {
163
320
  status: 405,
164
321
  headers: {
165
- allow: "GET, HEAD, OPTIONS, POST"
322
+ allow: "GET, HEAD, OPTIONS, POST, PUT"
166
323
  }
167
324
  });
168
325
  }
326
+ /**
327
+ * The cart capability (cart RFC): pre-checkout baskets over a backend
328
+ * cart. Carts have no status — they exist, or answer 404 once canceled or
329
+ * expired — and no payment or negotiation, which belong to checkout.
330
+ */
331
+ async handleCartRequest(request, route, client, sessionId, agent) {
332
+ if (!route.cartId) {
333
+ if (request.method !== "POST") {
334
+ return methodNotAllowed(request.method, "POST");
335
+ }
336
+ const input = await parseJsonBody(request, ACPCartCreateRequestSchema);
337
+ const cart = await this.createCartForItems(input.line_items, client);
338
+ const state2 = {
339
+ id: `cart_${crypto.randomUUID()}`,
340
+ cartId: cart.identifier.key,
341
+ sessionId,
342
+ ...agent ? { agentId: agent.id } : {},
343
+ ...input.buyer ? { buyer: input.buyer } : {},
344
+ expiresAt: this.getCartExpiry()
345
+ };
346
+ return jsonResponse(await this.toACPCart(state2, client), { status: 201 });
347
+ }
348
+ const state = await this.checkoutSessionStore.getCart(route.cartId);
349
+ if (!state) {
350
+ return acpErrorResponse(404, { type: "invalid_request", code: "not_found", message: `Cart not found: ${route.cartId}` });
351
+ }
352
+ if (route.cancel) {
353
+ if (request.method !== "POST") {
354
+ return methodNotAllowed(request.method, "POST");
355
+ }
356
+ const final = await this.toACPCart(state, client);
357
+ await this.checkoutSessionStore.deleteCart(state.id);
358
+ await client.cart.deleteCart?.({ cart: { key: state.cartId } });
359
+ return jsonResponse(final);
360
+ }
361
+ if (request.method === "GET" || request.method === "HEAD") {
362
+ return jsonResponse(await this.toACPCart(state, client), { omitBody: request.method === "HEAD" });
363
+ }
364
+ if (request.method === "PUT") {
365
+ const input = await parseJsonBody(request, ACPCartUpdateRequestSchema);
366
+ const updated = {
367
+ ...state,
368
+ cartId: await this.replaceCartItems(state.cartId, input.line_items, client),
369
+ buyer: mergeBuyer(state.buyer, input.buyer),
370
+ expiresAt: this.getCartExpiry()
371
+ };
372
+ return jsonResponse(await this.toACPCart(updated, client));
373
+ }
374
+ return methodNotAllowed(request.method, "GET, PUT");
375
+ }
376
+ getCartExpiry() {
377
+ return new Date(Date.now() + (this.options.checkoutSessionTtlSeconds ?? 60 * 60 * 24) * 1e3).toISOString();
378
+ }
379
+ /** A cart's authoritative state; totals are estimates without an address. */
380
+ async toACPCart(state, client) {
381
+ await this.checkoutSessionStore.putCart(state);
382
+ const cart = await unwrapACPResult(client.cart.getById({ cart: { key: state.cartId } }));
383
+ const products = await getProducts(cart.items.map((item) => item.variant.sku), client);
384
+ return {
385
+ id: state.id,
386
+ line_items: cart.items.map((item) => toACPLineItem(item, products.get(item.variant.sku))),
387
+ ...state.buyer ? { buyer: state.buyer } : {},
388
+ currency: cart.price.grandTotal.currency.toLowerCase(),
389
+ totals: toACPTotals(cart.price),
390
+ messages: await this.getStockMessages(cart, client),
391
+ expires_at: state.expiresAt
392
+ };
393
+ }
169
394
  getDiscoveryDocument(request) {
170
395
  const discovery = this.options.discovery ?? {};
171
- const version = discovery.apiVersion ?? ACP_DEFAULT_API_VERSION;
172
- const supportedVersions = discovery.supportedVersions ?? [version];
173
396
  const basePath = (this.options.basePath ?? "/acp").replace(/\/$/, "");
174
397
  const apiBaseUrl = discovery.apiBaseUrl ?? `${new URL(request.url).origin}${basePath}`;
398
+ const extensions = this.getDiscoveryExtensions();
399
+ const interventionTypes = [...new Set(this.options.interventions?.supported ?? [])];
175
400
  return {
176
401
  protocol: {
177
402
  name: "acp",
178
- version,
179
- supported_versions: supportedVersions,
403
+ version: ACP_API_VERSION,
404
+ supported_versions: ACP_SUPPORTED_API_VERSIONS,
180
405
  ...discovery.documentationUrl ? { documentation_url: discovery.documentationUrl } : {}
181
406
  },
182
407
  api_base_url: apiBaseUrl,
183
- transports: ["rest"],
408
+ transports: this.mcp ? ["rest", "mcp"] : ["rest"],
184
409
  capabilities: {
185
- services: this.options.productFeed ? ["checkout", "feeds"] : ["checkout"],
410
+ // The services enum is closed per version: checkout, orders,
411
+ // delegate_payment and carts (discovery RFC §4.2).
412
+ services: this.webhooks ? ["checkout", "orders", "carts"] : ["checkout", "carts"],
413
+ ...extensions.length > 0 ? { extensions } : {},
414
+ ...interventionTypes.length > 0 ? { intervention_types: interventionTypes } : {},
186
415
  ...discovery.supportedCurrencies ? { supported_currencies: discovery.supportedCurrencies } : {},
187
416
  ...discovery.supportedLocales ? { supported_locales: discovery.supportedLocales } : {}
188
417
  }
189
418
  };
190
419
  }
420
+ /** The extensions the server implements; extension PRs add theirs here. */
421
+ getDiscoveryExtensions() {
422
+ return this.supportsDiscounts ? [{ name: ACP_DISCOUNT_EXTENSION.name }] : [];
423
+ }
191
424
  getReadinessDocument() {
192
425
  return {
193
426
  name: this.options.name ?? "@reactionary/acp",
@@ -200,52 +433,14 @@ class ReactionaryACPServer {
200
433
  "GET /checkout_sessions/{checkout_session_id}",
201
434
  "POST /checkout_sessions/{checkout_session_id}/complete",
202
435
  "POST /checkout_sessions/{checkout_session_id}/cancel",
203
- "GET /product_feeds/{id}/products"
436
+ "POST /carts",
437
+ "GET /carts/{id}",
438
+ "PUT /carts/{id}",
439
+ "POST /carts/{id}/cancel"
204
440
  ]
205
441
  };
206
442
  }
207
- async getProductFeed(request, feedId, feed, client, requestContext, omitBody) {
208
- const format = new URL(request.url).searchParams.get("format") ?? "json";
209
- if (format !== "json" && format !== "jsonl") {
210
- return acpErrorResponse(400, {
211
- type: "invalid_request",
212
- code: "invalid",
213
- message: `Unsupported product feed format: ${format}`,
214
- param: "$.format"
215
- });
216
- }
217
- const generator = new ReactionaryFeedGenerator(client, {
218
- defaultFulfillmentCenterKeys: this.options.productFeed?.defaultFulfillmentCenterKeys,
219
- productConcurrency: this.options.productFeed?.productConcurrency
220
- });
221
- const output = acpProductFeedTransformer.transform(
222
- generator.products(feed, requestContext),
223
- {
224
- feedId,
225
- feed,
226
- options: { format }
227
- }
228
- );
229
- const extension = format === "jsonl" ? "jsonl" : "json";
230
- return new Response(
231
- omitBody ? null : createFeedStream(output),
232
- {
233
- headers: {
234
- "content-type": format === "jsonl" ? "application/x-ndjson; charset=utf-8" : "application/json; charset=utf-8",
235
- "content-disposition": `attachment; filename="${feedId}.products.${extension}"`
236
- }
237
- }
238
- );
239
- }
240
- getRequestedProductFeed(request) {
241
- const id = getProductFeedId(request, this.options.basePath);
242
- const feed = id ? this.getProductFeedDefinition(id) : void 0;
243
- return id && feed ? { id, feed } : void 0;
244
- }
245
- getProductFeedDefinition(feedId) {
246
- return this.options.productFeed?.feeds[feedId];
247
- }
248
- async handlePost(request, client, requestContext, sessionId) {
443
+ async handlePost(request, client, requestContext, sessionId, agent) {
249
444
  const checkoutSessionId = getCheckoutSessionId(
250
445
  request,
251
446
  this.options.basePath
@@ -255,7 +450,8 @@ class ReactionaryACPServer {
255
450
  await parseJsonBody(request, ACPCreateCheckoutSessionRequestSchema),
256
451
  client,
257
452
  requestContext,
258
- sessionId
453
+ sessionId,
454
+ agent
259
455
  );
260
456
  }
261
457
  if (isCheckoutSessionCompleteRequest(request, this.options.basePath)) {
@@ -266,7 +462,11 @@ class ReactionaryACPServer {
266
462
  );
267
463
  }
268
464
  if (isCheckoutSessionCancelRequest(request, this.options.basePath)) {
269
- return this.cancelCheckoutSession(checkoutSessionId, client);
465
+ return this.cancelCheckoutSession(
466
+ checkoutSessionId,
467
+ await parseOptionalJsonBody(request, ACPCancelCheckoutSessionRequestSchema),
468
+ client
469
+ );
270
470
  }
271
471
  return this.updateCheckoutSession(
272
472
  checkoutSessionId,
@@ -275,15 +475,28 @@ class ReactionaryACPServer {
275
475
  requestContext
276
476
  );
277
477
  }
278
- async createCheckoutSession(input, client, requestContext, sessionId) {
279
- const cart = await this.createCartForItems(input.items, client);
478
+ async createCheckoutSession(input, client, requestContext, sessionId, agent) {
479
+ const cart = await this.createCartForItems(input.line_items, client);
480
+ const discounts = await this.applyRequestedDiscounts(
481
+ cart.identifier.key,
482
+ getRequestedDiscountCodes(input.discounts, input.coupons),
483
+ input.capabilities,
484
+ client
485
+ );
280
486
  const state = {
487
+ ...discounts,
281
488
  id: `checkout_session_${crypto.randomUUID()}`,
282
489
  sessionId,
490
+ ...agent ? { agentId: agent.id } : {},
283
491
  cartId: cart.identifier.key,
492
+ currency: input.currency.toLowerCase(),
493
+ agentCapabilities: input.capabilities,
494
+ ...input.locale ? { locale: input.locale } : {},
495
+ ...input.timezone ? { timezone: input.timezone } : {},
496
+ ...input.affiliate_attribution ? { firstTouchAttribution: { ...input.affiliate_attribution, touchpoint: "first" } } : {},
284
497
  status: "not_ready_for_payment",
285
498
  buyer: input.buyer,
286
- fulfillmentAddress: input.fulfillment_address
499
+ fulfillmentDetails: input.fulfillment_details
287
500
  };
288
501
  return jsonResponse(
289
502
  await this.toACPCheckoutSession(state, client, requestContext),
@@ -299,12 +512,23 @@ class ReactionaryACPServer {
299
512
  message: "The checkout session can no longer be modified."
300
513
  });
301
514
  }
515
+ const cartId = input.line_items ? await this.replaceCartItems(state.cartId, input.line_items, client) : state.cartId;
516
+ const discounts = await this.applyRequestedDiscounts(
517
+ cartId,
518
+ getRequestedDiscountCodes(input.discounts, input.coupons),
519
+ state.agentCapabilities,
520
+ client
521
+ );
302
522
  const updatedState = {
303
523
  ...state,
304
- cartId: input.items ? (await this.createCartForItems(input.items, client)).identifier.key : state.cartId,
305
- buyer: input.buyer ?? state.buyer,
306
- fulfillmentAddress: input.fulfillment_address ?? state.fulfillmentAddress,
307
- fulfillmentOptionId: input.fulfillment_option_id ?? state.fulfillmentOptionId
524
+ ...discounts,
525
+ cartId,
526
+ buyer: mergeBuyer(state.buyer, input.buyer),
527
+ // Changes to the session invalidate a pending authentication.
528
+ status: state.status === "authentication_required" ? "not_ready_for_payment" : state.status,
529
+ authenticationMetadata: void 0,
530
+ fulfillmentDetails: input.fulfillment_details === null ? void 0 : input.fulfillment_details ?? state.fulfillmentDetails,
531
+ fulfillmentOptionId: input.selected_fulfillment_options === void 0 ? state.fulfillmentOptionId : input.selected_fulfillment_options?.[0]?.option_id
308
532
  };
309
533
  return jsonResponse(
310
534
  await this.toACPCheckoutSession(updatedState, client, requestContext)
@@ -326,7 +550,8 @@ class ReactionaryACPServer {
326
550
  }
327
551
  /**
328
552
  * 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`;
553
+ * payment the PSP has not authorized yet leaves the session
554
+ * `complete_in_progress`, without an order yet;
330
555
  * a repeated complete retries finalization.
331
556
  */
332
557
  async completeCheckoutSession(checkoutSessionId, input, client) {
@@ -338,22 +563,47 @@ class ReactionaryACPServer {
338
563
  message: "Canceled checkout sessions cannot be completed."
339
564
  });
340
565
  }
341
- const buyer = input.buyer ?? state.buyer;
342
- let current = { ...state, buyer };
566
+ const buyer = mergeBuyer(state.buyer, input.buyer);
567
+ let current = {
568
+ ...state,
569
+ buyer,
570
+ ...input.marketing_consents ? { marketingConsents: input.marketing_consents } : {},
571
+ // Attribution is write-once: the first last-touch claim wins.
572
+ ...input.affiliate_attribution && !state.lastTouchAttribution ? { lastTouchAttribution: { ...input.affiliate_attribution, touchpoint: "last" } } : {}
573
+ };
343
574
  if (!current.checkoutId) {
575
+ const handler = this.resolvePaymentHandler(input.payment_data);
344
576
  const view = await this.priceSession(current, client);
577
+ if (view.status === "ready_for_payment") {
578
+ const authentication = await this.checkAuthentication(current, input, handler, view, client);
579
+ if (authentication) {
580
+ return authentication;
581
+ }
582
+ }
345
583
  if (view.status !== "ready_for_payment") {
584
+ const blocking = view.messages?.find((message) => message.type === "error");
346
585
  return acpErrorResponse(400, {
347
586
  type: "invalid_request",
348
- code: "invalid",
349
- message: "The checkout session is missing the buyer email, fulfillment address or fulfillment option."
587
+ code: blocking?.code ?? "invalid",
588
+ message: blocking?.content ?? "The checkout session is not ready for payment.",
589
+ ...blocking?.param ? { param: blocking.param } : {}
350
590
  });
351
591
  }
352
- const checkout = await this.placeCheckout(current, input, client);
592
+ const checkout = await this.placeCheckout(current, input, handler, client);
593
+ if (!checkout) {
594
+ return jsonResponse(await this.toACPCheckoutSession(current, client, void 0, [{
595
+ type: "error",
596
+ code: "payment_declined",
597
+ param: "$.payment_data",
598
+ content_type: "plain",
599
+ content: "The payment was declined. Please try a different payment method.",
600
+ resolution: "requires_buyer_input"
601
+ }]));
602
+ }
353
603
  current = {
354
604
  ...current,
355
605
  checkoutId: checkout.identifier.key,
356
- status: "in_progress"
606
+ status: "complete_in_progress"
357
607
  };
358
608
  await this.checkoutSessionStore.put(current.id, current);
359
609
  }
@@ -368,39 +618,93 @@ class ReactionaryACPServer {
368
618
  (candidate) => Boolean(candidate.resultingOrder || candidate.readyForFinalization),
369
619
  wait
370
620
  );
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 };
621
+ const orderId = checkout.resultingOrder?.key ?? (checkout.readyForFinalization ? (await unwrapACPResult(client.checkout.finalizeCheckout({ checkout: checkout.identifier }))).resultingOrder?.key : void 0);
622
+ if (orderId) {
623
+ current = { ...current, status: "completed", orderId };
378
624
  }
379
625
  }
380
- return jsonResponse(await this.toACPCheckoutSession(current, client));
626
+ const pending = current.status === "completed" ? [] : [{
627
+ type: "info",
628
+ content_type: "plain",
629
+ content: "The payment is awaiting authorization. Retrieve the session, or complete it again with a new Idempotency-Key, to receive the order."
630
+ }];
631
+ return jsonResponse(await this.toACPCheckoutSession(current, client, void 0, pending));
381
632
  }
382
- async cancelCheckoutSession(checkoutSessionId, client) {
633
+ async cancelCheckoutSession(checkoutSessionId, input, client) {
383
634
  const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
384
- if (state.checkoutId || state.status === "completed" || state.status === "canceled") {
635
+ const placed = state.checkoutId && state.status !== "completed" ? await this.getPlacedView(state, client) : void 0;
636
+ if (state.status === "completed" || state.status === "canceled" || placed?.orderId) {
385
637
  return acpErrorResponse(405, {
386
638
  type: "invalid_request",
387
639
  code: "invalid",
388
640
  message: "Completed or canceled checkout sessions cannot be canceled."
389
641
  });
390
642
  }
643
+ if (input.intent_trace) {
644
+ await Promise.resolve(this.options.onIntentTrace?.(input.intent_trace, {
645
+ checkoutSessionId: state.id,
646
+ ...state.agentId ? { agentId: state.agentId } : {}
647
+ }))?.catch((error) => console.error("ACP: onIntentTrace failed:", error));
648
+ }
391
649
  return jsonResponse(
392
- await this.toACPCheckoutSession({ ...state, status: "canceled" }, client)
650
+ await this.toACPCheckoutSession({
651
+ ...state,
652
+ status: "canceled",
653
+ ...input.intent_trace ? { intentTrace: input.intent_trace } : {}
654
+ }, client, void 0, [{
655
+ type: "info",
656
+ content_type: "plain",
657
+ content: "Checkout session has been canceled."
658
+ }])
393
659
  );
394
660
  }
661
+ /**
662
+ * Gives the session's cart exactly the requested items (line items are a
663
+ * full replacement), updating it in place where the backend supports it.
664
+ * Otherwise a new cart replaces it and the old one is deleted, if possible.
665
+ * Returns the cart key.
666
+ */
667
+ async replaceCartItems(cartId, items, client) {
668
+ const cartCapability = client.cart;
669
+ if (!cartCapability.changeQuantity || !cartCapability.remove) {
670
+ const replacement = await this.createCartForItems(items, client);
671
+ await client.cart.deleteCart?.({ cart: { key: cartId } });
672
+ return replacement.identifier.key;
673
+ }
674
+ const requested = getRequestedQuantities(items);
675
+ let cart = await unwrapACPResult(client.cart.getById({ cart: { key: cartId } }));
676
+ for (const existing of cart.items) {
677
+ const wanted = requested.get(existing.variant.sku);
678
+ if (!wanted) {
679
+ cart = await unwrapACPResult(cartCapability.remove({ cart: cart.identifier, item: existing.identifier }));
680
+ } else if (wanted.quantity !== existing.quantity) {
681
+ cart = await unwrapACPResult(
682
+ cartCapability.changeQuantity({ cart: cart.identifier, item: existing.identifier, quantity: wanted.quantity }),
683
+ `$.line_items[${wanted.index}].quantity`
684
+ );
685
+ }
686
+ }
687
+ const existingSkus = new Set(cart.items.map((item) => item.variant.sku));
688
+ for (const [sku, { quantity, index }] of requested) {
689
+ if (!existingSkus.has(sku)) {
690
+ cart = await unwrapACPResult(
691
+ client.cart.add({ cart: cart.identifier, variant: { sku }, quantity }),
692
+ `$.line_items[${index}].id`
693
+ );
694
+ }
695
+ }
696
+ return cart.identifier.key;
697
+ }
395
698
  async createCartForItems(items, client) {
396
699
  let cart = await unwrapACPResult(client.cart.createCart({}));
397
- for (const item of items) {
700
+ for (const [sku, { quantity, index }] of getRequestedQuantities(items)) {
398
701
  cart = await unwrapACPResult(
399
702
  client.cart.add({
400
703
  cart: cart.identifier,
401
- variant: { sku: item.id },
402
- quantity: item.quantity
403
- })
704
+ variant: { sku },
705
+ quantity
706
+ }),
707
+ `$.line_items[${index}].id`
404
708
  );
405
709
  }
406
710
  return cart;
@@ -416,16 +720,23 @@ class ReactionaryACPServer {
416
720
  }
417
721
  return state;
418
722
  }
419
- async placeCheckout(state, input, client) {
723
+ async placeCheckout(state, input, handler, client) {
420
724
  const cart = await unwrapACPResult(client.cart.getById({ cart: { key: state.cartId } }));
725
+ const address = state.fulfillmentDetails?.address;
726
+ const billingAddress = input.payment_data.billing_address ?? address;
421
727
  let checkout = await unwrapACPResult(
422
728
  client.checkout.initiateCheckoutForCart({
423
729
  cart,
424
- billingAddress: state.fulfillmentAddress ? toReactionaryAddress(state.fulfillmentAddress) : void 0,
425
- notificationEmail: state.buyer?.email,
426
- notificationPhone: state.buyer?.phone_number
730
+ billingAddress: billingAddress ? toReactionaryAddress(billingAddress) : void 0,
731
+ notificationEmail: getContactEmail(state),
732
+ notificationPhone: getContactPhone(state)
427
733
  })
428
734
  );
735
+ if (address) {
736
+ checkout = await unwrapACPResult(
737
+ client.checkout.setShippingAddress({ checkout: checkout.identifier, shippingAddress: toReactionaryAddress(address) })
738
+ );
739
+ }
429
740
  if (state.fulfillmentOptionId) {
430
741
  checkout = await unwrapACPResult(
431
742
  client.checkout.setShippingInstruction(toShippingInstruction(checkout, state.fulfillmentOptionId))
@@ -435,25 +746,28 @@ class ReactionaryACPServer {
435
746
  checkout: checkout.identifier,
436
747
  paymentInstruction: {
437
748
  amount: checkout.price.grandTotal,
438
- paymentMethod: toPaymentMethodIdentifier(input.payment_data.provider),
749
+ paymentMethod: getHandlerPaymentMethod(handler),
439
750
  protocolData: [
440
751
  {
441
752
  key: "delegated_payment_token",
442
- value: input.payment_data.token
753
+ value: input.payment_data.instrument.credential.token
443
754
  },
444
755
  {
445
756
  key: "delegated_payment_provider",
446
- value: input.payment_data.provider
447
- }
757
+ value: handler.handler.psp
758
+ },
759
+ { key: "acp_payment_handler_id", value: handler.handler.id },
760
+ { key: "acp_payment_instrument_type", value: input.payment_data.instrument.type },
761
+ { key: "acp_payment_credential_type", value: input.payment_data.instrument.credential.type },
762
+ ...input.authentication_result ? [{ key: "acp_authentication_result", value: JSON.stringify(input.authentication_result) }] : []
448
763
  ]
449
764
  }
450
765
  });
451
766
  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
- });
767
+ if (checkout.identifier.key !== cart.identifier.key && client.cart.deleteCart) {
768
+ await client.cart.deleteCart({ cart: checkout.identifier });
769
+ }
770
+ return void 0;
457
771
  }
458
772
  return paid.value;
459
773
  }
@@ -465,18 +779,34 @@ class ReactionaryACPServer {
465
779
  */
466
780
  async priceSession(state, client) {
467
781
  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" };
782
+ if (state.status === "canceled") {
783
+ return { cart, price: cart.price, options: [], messages: [], status: "canceled" };
784
+ }
785
+ const stockMessages = await this.getStockMessages(cart, client);
786
+ const address = state.fulfillmentDetails?.address;
787
+ if (!address) {
788
+ return {
789
+ cart,
790
+ price: cart.price,
791
+ options: [],
792
+ messages: [...stockMessages, ...getInputMessages(state, [], false, this.options.requireFulfillment ?? true)],
793
+ status: "not_ready_for_payment"
794
+ };
470
795
  }
471
796
  let checkout = await unwrapACPResult(
472
797
  client.checkout.initiateCheckoutForCart({
473
798
  cart,
474
- billingAddress: toReactionaryAddress(state.fulfillmentAddress),
475
- notificationEmail: state.buyer?.email ?? this.options.placeholderEmail ?? DEFAULT_ACP_PLACEHOLDER_EMAIL,
476
- notificationPhone: state.buyer?.phone_number
799
+ billingAddress: toReactionaryAddress(address),
800
+ notificationEmail: getContactEmail(state) ?? this.options.placeholderEmail ?? DEFAULT_ACP_PLACEHOLDER_EMAIL,
801
+ notificationPhone: getContactPhone(state)
477
802
  })
478
803
  );
479
804
  try {
805
+ const withAddress = await client.checkout.setShippingAddress({
806
+ checkout: checkout.identifier,
807
+ shippingAddress: toReactionaryAddress(address)
808
+ });
809
+ checkout = withAddress.success ? withAddress.value : checkout;
480
810
  const shippingMethods = await client.checkout.getAvailableShippingMethods({ checkout: checkout.identifier });
481
811
  const options = shippingMethods.success ? shippingMethods.value : [];
482
812
  const selected = options.find((option) => option.identifier.key === state.fulfillmentOptionId);
@@ -486,8 +816,14 @@ class ReactionaryACPServer {
486
816
  );
487
817
  checkout = withShipping.success ? withShipping.value : checkout;
488
818
  }
489
- const ready = Boolean(state.buyer?.email) && (options.length === 0 || Boolean(selected));
819
+ const messages = [
820
+ ...stockMessages,
821
+ ...this.getInterventionMessages(state),
822
+ ...getInputMessages(state, options, true, this.options.requireFulfillment ?? true)
823
+ ];
824
+ const ready = messages.every((message) => message.type !== "error");
490
825
  return {
826
+ messages,
491
827
  cart,
492
828
  checkout,
493
829
  price: checkout.price,
@@ -500,68 +836,493 @@ class ReactionaryACPServer {
500
836
  }
501
837
  }
502
838
  }
503
- async toACPCheckoutSession(state, client, requestContext = createInitialRequestContext()) {
839
+ async toACPCheckoutSession(state, client, requestContext = createInitialRequestContext(), extraMessages = []) {
504
840
  const view = state.checkoutId ? await this.getPlacedView(state, client) : await this.priceSession(state, client);
505
- const persisted = { ...state, status: view.status };
841
+ const awaitingAuthentication = state.status === "authentication_required" && view.status === "ready_for_payment";
842
+ const now = (/* @__PURE__ */ new Date()).toISOString();
843
+ const persisted = {
844
+ ...state,
845
+ createdAt: state.createdAt ?? now,
846
+ updatedAt: now,
847
+ status: awaitingAuthentication ? "authentication_required" : view.status,
848
+ ...view.orderId ? { orderId: view.orderId } : {}
849
+ };
850
+ const lineItems = view.checkout?.items ?? view.cart?.items ?? [];
851
+ const products = await getProducts(lineItems.map((item) => item.variant.sku), client);
852
+ const optionTitles = getOptionTitles(view.options);
853
+ const selectedOption = view.options.find((option) => option.identifier.key === state.fulfillmentOptionId);
854
+ const discountsActive = this.isDiscountExtensionActive(persisted.agentCapabilities, client);
506
855
  await this.checkoutSessionStore.put(persisted.id, persisted);
856
+ await this.recordOrder(persisted, client);
507
857
  return {
508
858
  id: persisted.id,
859
+ protocol: { version: ACP_API_VERSION },
860
+ capabilities: this.getNegotiatedCapabilities(persisted.agentCapabilities, client),
509
861
  ...persisted.buyer ? { buyer: persisted.buyer } : {},
510
- payment_provider: await this.getPaymentProvider(view.checkout, client),
511
862
  status: persisted.status,
863
+ ...persisted.status === "authentication_required" && persisted.authenticationMetadata ? { authentication_metadata: persisted.authenticationMetadata } : {},
512
864
  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 } : {},
865
+ locale: persisted.locale ?? requestContext.languageContext.locale,
866
+ ...persisted.timezone ? { timezone: persisted.timezone } : {},
867
+ line_items: lineItems.map((item) => toACPLineItem(item, products.get(item.variant.sku))),
868
+ ...persisted.fulfillmentDetails ? { fulfillment_details: persisted.fulfillmentDetails } : {},
869
+ fulfillment_options: view.options.map((option) => toACPFulfillmentOption(
870
+ option,
871
+ optionTitles.get(option.identifier.key) ?? option.identifier.key,
872
+ this.classifyFulfillmentOption(option)
873
+ )),
874
+ // A selection is only reported while it references an offered option.
875
+ ...persisted.fulfillmentOptionId && view.options.some((option) => option.identifier.key === persisted.fulfillmentOptionId) ? {
876
+ selected_fulfillment_options: [{
877
+ type: selectedOption ? this.classifyFulfillmentOption(selectedOption).type : "shipping",
878
+ option_id: persisted.fulfillmentOptionId,
879
+ item_ids: lineItems.map((lineItem) => lineItem.identifier.key)
880
+ }]
881
+ } : {},
517
882
  totals: toACPTotals(view.price),
518
883
  ...persisted.orderId ? {
519
884
  order: {
885
+ type: "order",
520
886
  id: persisted.orderId,
521
887
  checkout_session_id: persisted.id,
522
- permalink_url: ""
888
+ ...this.options.orderPermalinkUrl ? { permalink_url: toOrderPermalinkUrl(this.options.orderPermalinkUrl, persisted.orderId) } : {}
523
889
  }
524
890
  } : {},
525
- messages: [],
526
- links: this.options.links ?? []
891
+ ...this.options.marketingConsent?.channels.length && persisted.status !== "completed" ? { marketing_consent_options: await this.getMarketingConsentOptions(persisted) } : {},
892
+ ...discountsActive && view.cart ? {
893
+ discounts: toACPDiscounts(view.cart, persisted.discountCodes ?? [], persisted.rejectedDiscounts ?? [], toMinorUnits)
894
+ } : {},
895
+ messages: [
896
+ ...extraMessages,
897
+ ...view.messages ?? [],
898
+ ...discountsActive ? getRejectedDiscountMessages(persisted) : []
899
+ ],
900
+ links: this.options.links ?? [],
901
+ created_at: persisted.createdAt,
902
+ updated_at: persisted.updatedAt,
903
+ ...isTerminal(persisted.status) ? {} : {
904
+ expires_at: new Date(Date.now() + (this.options.checkoutSessionTtlSeconds ?? 60 * 60 * 24) * 1e3).toISOString(),
905
+ ...this.options.continueUrl ? { continue_url: this.options.continueUrl.replaceAll("{checkoutSessionId}", encodeURIComponent(persisted.id)) } : {}
906
+ }
527
907
  };
528
908
  }
909
+ classifyFulfillmentOption(method) {
910
+ return this.options.classifyFulfillmentOption?.(method) ?? { type: "shipping" };
911
+ }
912
+ /**
913
+ * Whether the discount extension is active for a session: the backend can
914
+ * apply coupons, and the agent declared the extension.
915
+ */
916
+ isDiscountExtensionActive(agent, client) {
917
+ return Boolean(client.cart?.applyCouponCode && client.cart.removeCouponCode) && (agent?.extensions ?? []).some((extension) => /^discount(@|$)/.test(extension));
918
+ }
919
+ /** Applies submitted discount codes, when the extension is active. */
920
+ async applyRequestedDiscounts(cartId, codes, agent, client) {
921
+ if (!codes || !this.isDiscountExtensionActive(agent, client)) {
922
+ return {};
923
+ }
924
+ const cart = await unwrapACPResult(client.cart.getById({ cart: { key: cartId } }));
925
+ const { rejected } = await applyDiscountCodes(client.cart, cart, codes);
926
+ return { discountCodes: codes, rejectedDiscounts: rejected };
927
+ }
928
+ /**
929
+ * 3D Secure (checkout RFC §4.4): when the authentication hook asks for it,
930
+ * the first completion answers `authentication_required` with the
931
+ * metadata; completing such a session without `authentication_result` is
932
+ * a 4XX `requires_3ds`. Failed or refused authentications are not
933
+ * authorized. Returns a response when completion must stop here.
934
+ */
935
+ async checkAuthentication(state, input, handler, view, client) {
936
+ const result = input.authentication_result;
937
+ const metadata = state.authenticationMetadata ?? (this.options.authentication ? await this.options.authentication.getMetadata({
938
+ checkoutSessionId: state.id,
939
+ handler: handler.handler,
940
+ instrument: input.payment_data.instrument,
941
+ amount: view.price.grandTotal
942
+ }) : void 0);
943
+ if (!metadata) {
944
+ return void 0;
945
+ }
946
+ if (!result) {
947
+ if (state.status === "authentication_required") {
948
+ throw new ACPHttpError(400, {
949
+ type: "invalid_request",
950
+ code: "requires_3ds",
951
+ message: "This checkout session requires issuer authentication. The request must include 'authentication_result'.",
952
+ param: "$.authentication_result"
953
+ });
954
+ }
955
+ const pending = { ...state, status: "authentication_required", authenticationMetadata: metadata };
956
+ await this.checkoutSessionStore.put(pending.id, pending);
957
+ return jsonResponse(await this.toACPCheckoutSession(pending, client));
958
+ }
959
+ if (!ACP_AUTHENTICATED_OUTCOMES.includes(result.outcome)) {
960
+ const pending = { ...state, status: "authentication_required", authenticationMetadata: metadata };
961
+ await this.checkoutSessionStore.put(pending.id, pending);
962
+ return jsonResponse(await this.toACPCheckoutSession(pending, client, void 0, [{
963
+ type: "error",
964
+ code: "payment_declined",
965
+ param: "$.authentication_result",
966
+ content_type: "plain",
967
+ content: `Card authentication did not succeed (${result.outcome}); the payment was not authorized.`,
968
+ resolution: "requires_buyer_input"
969
+ }]));
970
+ }
971
+ return void 0;
972
+ }
973
+ /**
974
+ * Records an order placed through a session once, and announces it to the
975
+ * creating agent's webhook receiver.
976
+ */
977
+ async recordOrder(state, client) {
978
+ if (state.status !== "completed" || !state.orderId || await this.checkoutSessionStore.getOrder(state.orderId)) {
979
+ return;
980
+ }
981
+ const record = {
982
+ id: state.orderId,
983
+ checkoutSessionId: state.id,
984
+ ...state.sessionId ? { sessionId: state.sessionId } : {},
985
+ ...state.agentId ? { agentId: state.agentId } : {}
986
+ };
987
+ await this.checkoutSessionStore.putOrder(record);
988
+ await this.reportAttribution(state, record);
989
+ await this.reportMarketingConsents(state, record);
990
+ await this.sendOrderEvent("order_create", record, client);
991
+ }
992
+ /**
993
+ * Reports the order's affiliate attribution once it exists: first touch
994
+ * from the session's creation, last touch from its completion. A failing
995
+ * hook never blocks the checkout.
996
+ */
997
+ async reportAttribution(state, record) {
998
+ const firstTouch = ACPAffiliateAttributionSchema.safeParse(state.firstTouchAttribution);
999
+ const lastTouch = ACPAffiliateAttributionSchema.safeParse(state.lastTouchAttribution);
1000
+ if (!this.options.onOrderAttribution || !firstTouch.success && !lastTouch.success) {
1001
+ return;
1002
+ }
1003
+ try {
1004
+ await this.options.onOrderAttribution({
1005
+ orderId: record.id,
1006
+ checkoutSessionId: record.checkoutSessionId,
1007
+ ...record.agentId ? { agentId: record.agentId } : {},
1008
+ ...firstTouch.success ? { firstTouch: firstTouch.data } : {},
1009
+ ...lastTouch.success ? { lastTouch: lastTouch.data } : {}
1010
+ });
1011
+ } catch {
1012
+ console.error(`ACP: onOrderAttribution failed for order ${record.id}.`);
1013
+ }
1014
+ }
1015
+ /**
1016
+ * Passes the buyer's consent decisions on, once the order exists. Only
1017
+ * offered channels with a resolvable contact count: email uses the buyer's
1018
+ * email, falling back to the fulfillment email; sms and whatsapp the phone
1019
+ * likewise (marketing consent RFC §3.5). Omitted channels are not consent.
1020
+ */
1021
+ async reportMarketingConsents(state, record) {
1022
+ const options = this.options.marketingConsent;
1023
+ const offered = new Set(options?.channels.map((channel) => channel.channel));
1024
+ const consents = (state.marketingConsents ?? []).flatMap((consent) => {
1025
+ const contact = getConsentContact(state, consent.channel);
1026
+ return offered.has(consent.channel) && contact ? [{ channel: consent.channel, opted_in: consent.opted_in, contact }] : [];
1027
+ });
1028
+ if (!options || consents.length === 0) {
1029
+ return;
1030
+ }
1031
+ try {
1032
+ await options.onConsents(consents, {
1033
+ orderId: record.id,
1034
+ checkoutSessionId: record.checkoutSessionId,
1035
+ ...record.agentId ? { agentId: record.agentId } : {}
1036
+ });
1037
+ } catch (error) {
1038
+ console.error(`ACP: marketing consents for order ${record.id} could not be recorded:`, error);
1039
+ }
1040
+ }
1041
+ /** The offered marketing channels, with the buyer's subscription state. */
1042
+ async getMarketingConsentOptions(state) {
1043
+ const options = this.options.marketingConsent;
1044
+ return Promise.all((options?.channels ?? []).map(async (channel) => {
1045
+ const contact = getConsentContact(state, channel.channel);
1046
+ const subscribed = contact && options?.isSubscribed ? await options.isSubscribed(contact, channel.channel) : false;
1047
+ return { ...channel, is_subscribed: subscribed };
1048
+ }));
1049
+ }
1050
+ async sendOrderEvent(type, record, client) {
1051
+ const endpoint = this.webhooks?.getEndpoint(record.agentId);
1052
+ const order = endpoint ? await this.toACPOrder(record, client) : void 0;
1053
+ if (!this.webhooks || !endpoint || !order) {
1054
+ return false;
1055
+ }
1056
+ this.webhooks.deliver(endpoint, type, order);
1057
+ return true;
1058
+ }
1059
+ /** The full current order (webhooks carry the whole order, not deltas). */
1060
+ async toACPOrder(record, client) {
1061
+ const result = await client.order?.getById({ order: { key: record.id } });
1062
+ if (!result?.success) {
1063
+ console.error(`ACP: order ${record.id} could not be read for its webhook:`, result?.error);
1064
+ return void 0;
1065
+ }
1066
+ const order = result.value;
1067
+ const products = await getProducts(order.items.map((item) => item.variant.sku), client);
1068
+ const canceled = order.orderStatus === "Cancelled";
1069
+ const shipped = order.orderStatus === "Shipped";
1070
+ const lineItems = order.items.map((item) => {
1071
+ const product = products.get(item.variant.sku);
1072
+ const current = canceled ? 0 : item.quantity;
1073
+ return {
1074
+ id: item.identifier.key,
1075
+ title: product?.name || item.variant.sku,
1076
+ ...product ? { product_id: product.identifier.key } : {},
1077
+ quantity: { ordered: item.quantity, current, fulfilled: shipped ? current : 0 },
1078
+ unit_price: toMinorUnits(item.price.unitPrice),
1079
+ subtotal: toMinorUnits(item.price.totalPrice),
1080
+ status: canceled ? "removed" : shipped ? "fulfilled" : "processing"
1081
+ };
1082
+ });
1083
+ return {
1084
+ type: "order",
1085
+ id: order.identifier.key,
1086
+ checkout_session_id: record.checkoutSessionId,
1087
+ ...this.options.orderPermalinkUrl ? { permalink_url: toOrderPermalinkUrl(this.options.orderPermalinkUrl, order.identifier.key) } : {},
1088
+ status: toACPOrderStatus(order.orderStatus),
1089
+ line_items: lineItems,
1090
+ ...order.shippingMethod ? {
1091
+ fulfillments: [{
1092
+ id: "fulfillment_1",
1093
+ // Order fulfillments are shipping, pickup or digital.
1094
+ type: toOrderFulfillmentType(this.classifyFulfillmentOption(order.shippingMethod).type),
1095
+ status: canceled ? "canceled" : shipped ? "shipped" : "processing",
1096
+ line_items: lineItems.filter((lineItem) => lineItem.quantity.current > 0).map((lineItem) => ({ id: lineItem.id, quantity: lineItem.quantity.current })),
1097
+ ...order.shippingMethod.carrier ? { carrier: order.shippingMethod.carrier } : {}
1098
+ }]
1099
+ } : {},
1100
+ totals: toACPTotals(order.price)
1101
+ };
1102
+ }
1103
+ /**
1104
+ * The advertised handler a completion pays with. Sellers MUST check the
1105
+ * handler is one they declared (sellers guide, "Complete a checkout
1106
+ * session"), and raw card numbers are refused unless explicitly accepted,
1107
+ * so they never reach the backend's persisted payment data.
1108
+ */
1109
+ resolvePaymentHandler(paymentData) {
1110
+ const option = this.options.paymentHandlers?.find((candidate) => candidate.handler.id === paymentData.handler_id);
1111
+ if (!option) {
1112
+ throw new ACPHttpError(400, {
1113
+ type: "invalid_request",
1114
+ code: "invalid",
1115
+ message: `Payment handler '${paymentData.handler_id}' is not offered for this checkout.`,
1116
+ param: "$.payment_data.handler_id"
1117
+ });
1118
+ }
1119
+ if (!this.options.acceptRawCardCredentials && isRawCardCredential(paymentData.instrument.credential)) {
1120
+ throw new ACPHttpError(400, {
1121
+ type: "invalid_request",
1122
+ code: "invalid",
1123
+ message: "Raw card credentials are not accepted; submit a delegated payment token instead.",
1124
+ param: "$.payment_data.instrument.credential"
1125
+ });
1126
+ }
1127
+ return option;
1128
+ }
1129
+ /**
1130
+ * The seller's capabilities for a session: interventions are the
1131
+ * intersection of what the agent declared and what the seller supports,
1132
+ * plus the seller's requirements (capability negotiation RFC §4.5.1).
1133
+ */
1134
+ getNegotiatedCapabilities(agent, client) {
1135
+ const seller = this.options.interventions;
1136
+ const required = seller?.required ?? [];
1137
+ const handlers = this.options.paymentHandlers ?? [];
1138
+ return {
1139
+ ...handlers.length > 0 ? { payment: { handlers: handlers.map((option) => option.handler) } } : {},
1140
+ interventions: {
1141
+ supported: getSupportedInterventions(agent, seller),
1142
+ ...required.length > 0 ? { required, enforcement: seller?.enforcement ?? "conditional" } : {}
1143
+ },
1144
+ ...this.isDiscountExtensionActive(agent, client) ? { extensions: [ACP_DISCOUNT_EXTENSION] } : {}
1145
+ };
1146
+ }
1147
+ /**
1148
+ * An out_of_stock error per line item requesting more than the configured
1149
+ * fulfillment centers hold (as in the UCP adapter). Back-ordered and
1150
+ * pre-ordered items are always purchasable; without configured centers,
1151
+ * stock is left to the backend.
1152
+ */
1153
+ async getStockMessages(cart, client) {
1154
+ const fulfillmentCenterKeys = this.options.inventory?.fulfillmentCenterKeys ?? [];
1155
+ const inventoryCapability = client.inventory;
1156
+ if (fulfillmentCenterKeys.length === 0 || !inventoryCapability) {
1157
+ return [];
1158
+ }
1159
+ const messages = await Promise.all(cart.items.map(async (item, index) => {
1160
+ const results = await Promise.all(fulfillmentCenterKeys.map((key) => inventoryCapability.getBySKU({
1161
+ variant: item.variant,
1162
+ fulfilmentCenter: { key }
1163
+ })));
1164
+ const inventories = results.flatMap((result) => result.success ? [result.value] : []);
1165
+ if (inventories.length === 0 || inventories.some((inventory) => inventory.status === "onBackOrder" || inventory.status === "preOrder")) {
1166
+ return void 0;
1167
+ }
1168
+ const available = inventories.reduce(
1169
+ (total, inventory) => total + (inventory.status === "inStock" ? Math.max(inventory.quantity, 0) : 0),
1170
+ 0
1171
+ );
1172
+ if (item.quantity <= available) {
1173
+ return void 0;
1174
+ }
1175
+ const sku = item.variant.sku;
1176
+ return {
1177
+ type: "error",
1178
+ code: "out_of_stock",
1179
+ param: `$.line_items[${index}]`,
1180
+ content_type: "plain",
1181
+ content: available > 0 ? `Only ${available} of ${sku} are in stock.` : `${sku} is out of stock.`,
1182
+ resolution: "requires_buyer_input"
1183
+ };
1184
+ }));
1185
+ return messages.filter((message) => message !== void 0);
1186
+ }
1187
+ /**
1188
+ * Required interventions the agent cannot perform block the session when
1189
+ * they always apply (checkout RFC §5, intervention_required).
1190
+ */
1191
+ getInterventionMessages(state) {
1192
+ const seller = this.options.interventions;
1193
+ const supported = getSupportedInterventions(state.agentCapabilities, seller);
1194
+ const missing = (seller?.required ?? []).filter((type) => !supported.includes(type));
1195
+ if (missing.length === 0 || seller?.enforcement !== "always") {
1196
+ return [];
1197
+ }
1198
+ return [{
1199
+ type: "error",
1200
+ code: "intervention_required",
1201
+ param: "$.capabilities.interventions",
1202
+ content_type: "plain",
1203
+ content: `This checkout requires ${missing.join(", ")}, which the agent does not support.`
1204
+ }];
1205
+ }
529
1206
  async getPlacedView(state, client) {
530
1207
  const checkout = await unwrapACPResult(
531
1208
  client.checkout.getById({ identifier: { key: state.checkoutId ?? "" } })
532
1209
  );
1210
+ const orderId = state.orderId ?? checkout.resultingOrder?.key;
1211
+ const status = orderId ? "completed" : state.status === "canceled" ? "canceled" : "complete_in_progress";
1212
+ const methods = state.fulfillmentOptionId ? await client.checkout.getAvailableShippingMethods({ checkout: checkout.identifier }) : void 0;
1213
+ const options = methods?.success ? methods.value.filter((method) => method.identifier.key === state.fulfillmentOptionId) : [];
533
1214
  return {
534
1215
  checkout,
535
1216
  price: checkout.price,
536
- options: [],
537
- status: state.status === "completed" || checkout.resultingOrder ? "completed" : "in_progress"
1217
+ options,
1218
+ status,
1219
+ ...orderId ? { orderId } : {}
538
1220
  };
539
1221
  }
540
- async getPaymentProvider(checkout, client) {
541
- if (this.options.paymentProvider) {
542
- return this.options.paymentProvider;
543
- }
544
- const result = checkout ? await client.checkout.getAvailablePaymentMethods({
545
- checkout: checkout.identifier
546
- }) : void 0;
547
- if (result?.success) {
548
- const supported = result.value.find(
549
- (method) => ["stripe", "adyen", "braintree"].includes(
550
- method.identifier.paymentProcessor
551
- )
552
- );
553
- if (supported) {
554
- return {
555
- provider: supported.identifier.paymentProcessor,
556
- supported_payment_methods: ["card"]
557
- };
558
- }
1222
+ }
1223
+ function getRequestedQuantities(items) {
1224
+ const quantities = /* @__PURE__ */ new Map();
1225
+ for (const [index, item] of items.entries()) {
1226
+ const current = quantities.get(item.id);
1227
+ quantities.set(item.id, { quantity: (current?.quantity ?? 0) + (item.quantity ?? 1), index: current?.index ?? index });
1228
+ }
1229
+ return quantities;
1230
+ }
1231
+ async function getProducts(skus, client) {
1232
+ const products = /* @__PURE__ */ new Map();
1233
+ await Promise.all([...new Set(skus)].map(async (sku) => {
1234
+ const result = await client.product.getBySKU({ variant: { sku } });
1235
+ if (result.success) {
1236
+ products.set(sku, result.value);
559
1237
  }
560
- return {
561
- provider: "stripe",
562
- supported_payment_methods: ["card"]
563
- };
1238
+ }));
1239
+ return products;
1240
+ }
1241
+ function secureEquals(left, right) {
1242
+ const leftDigest = createHash("sha256").update(left).digest();
1243
+ const rightDigest = createHash("sha256").update(right).digest();
1244
+ return timingSafeEqual(leftDigest, rightDigest);
1245
+ }
1246
+ function isRawCardCredential(credential) {
1247
+ return credential["type"] === "card" || credential["type"] === "pan" || ["number", "cvc", "card_number"].some((field) => credential[field] !== void 0);
1248
+ }
1249
+ function toOrderFulfillmentType(type) {
1250
+ return type === "local_delivery" ? "shipping" : type;
1251
+ }
1252
+ function isTerminal(status) {
1253
+ return status === "completed" || status === "canceled";
1254
+ }
1255
+ function getConsentContact(state, channel) {
1256
+ if (channel === "email") {
1257
+ return getContactEmail(state);
1258
+ }
1259
+ return channel === "sms" || channel === "whatsapp" ? getContactPhone(state) : void 0;
1260
+ }
1261
+ function getRejectedDiscountMessages(state) {
1262
+ const codes = state.discountCodes ?? [];
1263
+ return (state.rejectedDiscounts ?? []).map((rejected) => ({
1264
+ type: "warning",
1265
+ code: rejected.reason,
1266
+ param: `$.discounts.codes[${Math.max(codes.indexOf(rejected.code), 0)}]`,
1267
+ content_type: "plain",
1268
+ content: rejected.message ?? `Discount code '${rejected.code}' could not be applied.`,
1269
+ resolution: "requires_buyer_input"
1270
+ }));
1271
+ }
1272
+ function getInputMessages(state, options, hasAddress, requireFulfillment) {
1273
+ const messages = [];
1274
+ const missing = (param, content) => ({
1275
+ type: "error",
1276
+ code: "missing",
1277
+ param,
1278
+ content_type: "plain",
1279
+ content,
1280
+ resolution: "requires_buyer_input"
1281
+ });
1282
+ if (!getContactEmail(state)) {
1283
+ messages.push(missing("$.buyer.email", "A buyer email is required."));
1284
+ }
1285
+ if (!hasAddress) {
1286
+ messages.push(missing("$.fulfillment_details.address", "A shipping address is required."));
564
1287
  }
1288
+ const selected = options.some((option) => option.identifier.key === state.fulfillmentOptionId);
1289
+ if (state.fulfillmentOptionId && hasAddress && !selected) {
1290
+ messages.push({
1291
+ type: "error",
1292
+ code: "invalid",
1293
+ param: "$.selected_fulfillment_options[0].option_id",
1294
+ content_type: "plain",
1295
+ content: `Fulfillment option '${state.fulfillmentOptionId}' is not available for this checkout.`,
1296
+ resolution: "recoverable"
1297
+ });
1298
+ } else if (options.length > 0 && !state.fulfillmentOptionId) {
1299
+ messages.push(missing("$.selected_fulfillment_options", "A fulfillment option must be selected."));
1300
+ } else if (options.length === 0 && hasAddress && requireFulfillment) {
1301
+ messages.push({
1302
+ type: "error",
1303
+ code: "region_restricted",
1304
+ param: "$.fulfillment_details.address",
1305
+ content_type: "plain",
1306
+ content: "No fulfillment option is available for this address.",
1307
+ resolution: "requires_buyer_input"
1308
+ });
1309
+ }
1310
+ return messages;
1311
+ }
1312
+ function getSupportedInterventions(agent, seller) {
1313
+ const declared = new Set(agent?.interventions?.supported ?? []);
1314
+ return (seller?.supported ?? []).filter(
1315
+ (type) => ACP_INTERVENTION_TYPES.includes(type) && declared.has(type)
1316
+ );
1317
+ }
1318
+ function getContactEmail(state) {
1319
+ return state.buyer?.email ?? state.fulfillmentDetails?.email;
1320
+ }
1321
+ function getContactPhone(state) {
1322
+ return state.buyer?.phone_number ?? state.fulfillmentDetails?.phone_number;
1323
+ }
1324
+ function mergeBuyer(current, update) {
1325
+ return update ? { ...current, ...update } : current;
565
1326
  }
566
1327
  function toShippingInstruction(checkout, fulfillmentOptionId) {
567
1328
  return {
@@ -584,13 +1345,27 @@ async function pollUntil(read, done, wait) {
584
1345
  return value;
585
1346
  }
586
1347
  class ACPHttpError extends Error {
587
- constructor(status, body) {
1348
+ constructor(status, body, headers = {}) {
588
1349
  super(body.message);
589
1350
  this.status = status;
590
1351
  this.body = body;
1352
+ this.headers = headers;
591
1353
  }
592
1354
  status;
593
1355
  body;
1356
+ headers;
1357
+ }
1358
+ function assertSupportedApiVersion(request) {
1359
+ const version = request.headers.get(ACP_API_VERSION_HEADER);
1360
+ if (version && ACP_SUPPORTED_API_VERSIONS.includes(version)) {
1361
+ return;
1362
+ }
1363
+ throw new ACPHttpError(400, {
1364
+ type: "invalid_request",
1365
+ code: version ? "unsupported_api_version" : "missing_api_version",
1366
+ message: version ? `API version '${version}' is not supported.` : "The API-Version header is required.",
1367
+ supported_versions: ACP_SUPPORTED_API_VERSIONS
1368
+ });
594
1369
  }
595
1370
  class ReactionaryACPSessionStore {
596
1371
  constructor(cache, ttlSeconds) {
@@ -620,12 +1395,14 @@ class ReactionaryACPSessionStore {
620
1395
  }
621
1396
  }
622
1397
  class ReactionaryACPCheckoutSessionStore {
623
- constructor(cache, ttlSeconds) {
1398
+ constructor(cache, ttlSeconds, orderTtlSeconds) {
624
1399
  this.cache = cache;
625
1400
  this.ttlSeconds = ttlSeconds;
1401
+ this.orderTtlSeconds = orderTtlSeconds;
626
1402
  }
627
1403
  cache;
628
1404
  ttlSeconds;
1405
+ orderTtlSeconds;
629
1406
  async get(checkoutSessionId) {
630
1407
  return await this.cache.get(
631
1408
  this.getCacheKey(checkoutSessionId),
@@ -639,6 +1416,26 @@ class ReactionaryACPCheckoutSessionStore {
639
1416
  dependencyIds: [this.getDependencyId(checkoutSessionId)]
640
1417
  });
641
1418
  }
1419
+ async getCart(id) {
1420
+ const state = await this.cache.get(`${CART_CACHE_KEY_PREFIX}:${id}`, ACPCartStateSchema);
1421
+ return state && Date.parse(state.expiresAt) > Date.now() ? state : void 0;
1422
+ }
1423
+ async putCart(state) {
1424
+ const key = `${CART_CACHE_KEY_PREFIX}:${state.id}`;
1425
+ await this.cache.invalidate([key]);
1426
+ await this.cache.put(key, state, { ttlSeconds: this.ttlSeconds, dependencyIds: [key] });
1427
+ }
1428
+ async deleteCart(id) {
1429
+ await this.cache.invalidate([`${CART_CACHE_KEY_PREFIX}:${id}`]);
1430
+ }
1431
+ async getOrder(orderId) {
1432
+ return await this.cache.get(`${ORDER_CACHE_KEY_PREFIX}:${orderId}`, ACPOrderRecordSchema) ?? void 0;
1433
+ }
1434
+ async putOrder(record) {
1435
+ const key = `${ORDER_CACHE_KEY_PREFIX}:${record.id}`;
1436
+ await this.cache.invalidate([key]);
1437
+ await this.cache.put(key, record, { ttlSeconds: this.orderTtlSeconds, dependencyIds: [key] });
1438
+ }
642
1439
  getCacheKey(checkoutSessionId) {
643
1440
  return `${CHECKOUT_SESSION_CACHE_KEY_PREFIX}:${checkoutSessionId}`;
644
1441
  }
@@ -659,10 +1456,52 @@ function getCheckoutSessionId(request, basePath) {
659
1456
  );
660
1457
  return match?.[1];
661
1458
  }
662
- function getProductFeedId(request, basePath) {
1459
+ function toACPOrderStatus(status) {
1460
+ switch (status) {
1461
+ case "AwaitingPayment":
1462
+ return "created";
1463
+ case "ReleasedToFulfillment":
1464
+ return "confirmed";
1465
+ case "Shipped":
1466
+ return "shipped";
1467
+ case "Cancelled":
1468
+ return "canceled";
1469
+ }
1470
+ }
1471
+ function toOrderPermalinkUrl(template, orderId) {
1472
+ return template.replaceAll("{orderId}", encodeURIComponent(orderId));
1473
+ }
1474
+ function echoRequestHeaders(request, response) {
1475
+ const requestId = request.headers.get("request-id");
1476
+ const idempotencyKey = request.headers.get("idempotency-key");
1477
+ if (requestId) {
1478
+ response.headers.set("request-id", requestId);
1479
+ }
1480
+ if (idempotencyKey && request.method === "POST") {
1481
+ response.headers.set("idempotency-key", idempotencyKey);
1482
+ }
1483
+ return response;
1484
+ }
1485
+ function parseAcceptLanguage(header) {
1486
+ return (header ?? "").split(",").map((part, index) => {
1487
+ const [tag = "", ...parameters] = part.trim().split(";");
1488
+ const quality = parameters.map((parameter) => /^\s*q=([0-9.]+)\s*$/.exec(parameter)?.[1]).find((value) => value !== void 0);
1489
+ return { tag: tag.trim(), quality: quality === void 0 ? 1 : Number(quality), index };
1490
+ }).filter((entry) => entry.tag && entry.tag !== "*" && entry.quality > 0).sort((left, right) => right.quality - left.quality || left.index - right.index).map((entry) => entry.tag);
1491
+ }
1492
+ function isCheckoutPost(request, basePath) {
663
1493
  const pathname = getProtocolPathname(request, basePath);
664
- const match = /^\/product_feeds\/([^/]+)\/products$/.exec(pathname);
665
- return match?.[1];
1494
+ return request.method === "POST" && (pathname.startsWith("/checkout_sessions") || pathname === "/carts" || pathname.startsWith("/carts/"));
1495
+ }
1496
+ function getCartRoute(request, basePath) {
1497
+ const match = /^\/carts(?:\/([^/]+)(\/cancel)?)?$/.exec(getProtocolPathname(request, basePath));
1498
+ return match ? { ...match[1] ? { cartId: decodeURIComponent(match[1]) } : {}, cancel: Boolean(match[2]) } : void 0;
1499
+ }
1500
+ function methodNotAllowed(method, allow) {
1501
+ return jsonResponse(
1502
+ { type: "invalid_request", code: "method_not_allowed", message: `Unsupported method: ${method}` },
1503
+ { status: 405, headers: { allow } }
1504
+ );
666
1505
  }
667
1506
  function isCheckoutSessionCompleteRequest(request, basePath) {
668
1507
  return getProtocolPathname(request, basePath).endsWith("/complete");
@@ -681,15 +1520,37 @@ function getProtocolPathname(request, basePath = "/acp") {
681
1520
  return pathname;
682
1521
  }
683
1522
  async function parseJsonBody(request, schema) {
1523
+ let body;
684
1524
  try {
685
- return schema.parse(await request.json());
686
- } catch (error) {
1525
+ body = await request.json();
1526
+ } catch {
687
1527
  throw new ACPHttpError(400, {
688
1528
  type: "invalid_request",
689
- code: "invalid",
690
- message: error instanceof Error ? error.message : "Invalid request body"
1529
+ code: "invalid_json",
1530
+ message: "The request body must be valid JSON."
1531
+ });
1532
+ }
1533
+ const parsed = schema.safeParse(body);
1534
+ if (!parsed.success) {
1535
+ const issue = parsed.error.issues[0];
1536
+ throw new ACPHttpError(400, {
1537
+ type: "invalid_request",
1538
+ code: issue?.code === "invalid_type" && /received undefined/.test(issue.message) ? "missing" : "invalid",
1539
+ message: issue?.message ?? "Invalid request body",
1540
+ ...issue ? { param: toJsonPath(issue.path) } : {}
691
1541
  });
692
1542
  }
1543
+ return parsed.data;
1544
+ }
1545
+ async function parseOptionalJsonBody(request, schema) {
1546
+ const text = await request.clone().text();
1547
+ return text.trim() ? parseJsonBody(request, schema) : schema.parse({});
1548
+ }
1549
+ function toJsonPath(path) {
1550
+ return path.reduce(
1551
+ (jsonPath, segment) => typeof segment === "number" ? `${jsonPath}[${segment}]` : `${jsonPath}.${String(segment)}`,
1552
+ "$"
1553
+ );
693
1554
  }
694
1555
  function jsonResponse(body, options = {}) {
695
1556
  const headers = new Headers(options.headers);
@@ -705,6 +1566,18 @@ function jsonResponse(body, options = {}) {
705
1566
  function acpErrorResponse(status, body) {
706
1567
  return jsonResponse(body, { status });
707
1568
  }
1569
+ function assertPaymentHandlers(options) {
1570
+ const ids = /* @__PURE__ */ new Set();
1571
+ for (const { handler } of options) {
1572
+ if (ids.has(handler.id)) {
1573
+ throw new Error(`ACP payment handler ids must be unique: ${handler.id}`);
1574
+ }
1575
+ ids.add(handler.id);
1576
+ if (!handler.config.merchant_id || handler.config.psp !== handler.psp) {
1577
+ throw new Error(`ACP payment handler ${handler.id} must configure merchant_id and the handler's psp.`);
1578
+ }
1579
+ }
1580
+ }
708
1581
  function assertACPClient(client) {
709
1582
  const missing = getMissingACPClientOperations(client);
710
1583
  if (missing.length > 0) {
@@ -715,12 +1588,13 @@ function assertACPClient(client) {
715
1588
  }
716
1589
  function toACPErrorResponse(error) {
717
1590
  if (error instanceof ACPHttpError) {
718
- return acpErrorResponse(error.status, error.body);
1591
+ return jsonResponse(error.body, { status: error.status, headers: error.headers });
719
1592
  }
1593
+ console.error("ACP: request failed:", error);
720
1594
  return acpErrorResponse(500, {
721
1595
  type: "processing_error",
722
1596
  code: "internal_error",
723
- message: error instanceof Error ? error.message : String(error)
1597
+ message: "An unexpected error occurred."
724
1598
  });
725
1599
  }
726
1600
  function getMissingACPClientOperations(client) {
@@ -732,55 +1606,69 @@ function getMissingACPClientOperations(client) {
732
1606
  ["checkout.getById", client.checkout?.getById],
733
1607
  ["checkout.setShippingAddress", client.checkout?.setShippingAddress],
734
1608
  ["checkout.getAvailableShippingMethods", client.checkout?.getAvailableShippingMethods],
735
- ["checkout.getAvailablePaymentMethods", client.checkout?.getAvailablePaymentMethods],
736
1609
  ["checkout.setShippingInstruction", client.checkout?.setShippingInstruction],
737
1610
  ["checkout.addPaymentInstruction", client.checkout?.addPaymentInstruction],
738
1611
  ["checkout.finalizeCheckout", client.checkout?.finalizeCheckout],
739
- ["productSearch.queryByTerm", client.productSearch?.queryByTerm],
740
- ["product.getBySKU", client.product?.getBySKU],
741
- ["price.getListPrice", client.price?.getListPrice],
742
- ["price.getCustomerPrice", client.price?.getCustomerPrice],
743
- ["inventory.getBySKU", client.inventory?.getBySKU]
1612
+ ["product.getBySKU", client.product?.getBySKU]
744
1613
  ].filter(([, operation]) => typeof operation !== "function").map(([name]) => name);
745
1614
  }
746
- async function unwrapACPResult(resultPromise) {
1615
+ async function unwrapACPResult(resultPromise, param) {
747
1616
  const result = await resultPromise;
748
- if (!result.success) {
1617
+ if (result.success) {
1618
+ return result.value;
1619
+ }
1620
+ const errorType = typeof result.error === "object" && result.error !== null ? Reflect.get(result.error, "type") : void 0;
1621
+ if (param && (errorType === "NotFound" || errorType === "InvalidInput")) {
749
1622
  throw new ACPHttpError(400, {
750
- type: "processing_error",
751
- code: "reactionary_error",
752
- message: JSON.stringify(result.error)
1623
+ type: "invalid_request",
1624
+ code: errorType === "NotFound" ? "not_found" : "invalid",
1625
+ message: errorType === "NotFound" ? "The referenced resource does not exist." : "The request was rejected by the commerce backend.",
1626
+ param
753
1627
  });
754
1628
  }
755
- return result.value;
1629
+ console.error("ACP: commerce backend operation failed:", result.error);
1630
+ throw new ACPHttpError(502, {
1631
+ type: "processing_error",
1632
+ code: "backend_error",
1633
+ message: "The commerce backend could not process the request."
1634
+ });
756
1635
  }
757
1636
  function getCurrency(price, requestContext) {
758
1637
  return (price.grandTotal.currency ?? requestContext.languageContext.currencyCode).toLowerCase();
759
1638
  }
760
- function toACPLineItem(item) {
761
- const baseAmount = toMinorUnits(item.price.unitPrice.value * item.quantity);
762
- const discount = toMinorUnits(item.price.totalDiscount.value);
763
- const total = toMinorUnits(item.price.totalPrice.value);
1639
+ function toACPLineItem(item, product) {
1640
+ const sku = item.variant.sku;
1641
+ const variant = product ? [product.mainVariant, ...product.variants].find((candidate) => candidate.identifier.sku === sku) : void 0;
1642
+ const baseAmount = toMinorUnits({ value: item.price.unitPrice.value * item.quantity, currency: item.price.unitPrice.currency });
1643
+ const discount = Math.abs(toMinorUnits(item.price.totalDiscount));
1644
+ const images = (variant?.images ?? product?.mainVariant.images ?? []).map((image) => image.sourceUrl).filter((url) => url.length > 0);
1645
+ const name = variant?.name || product?.name;
764
1646
  return {
765
1647
  id: item.identifier.key,
766
- item: {
767
- id: item.variant.sku,
768
- quantity: item.quantity
769
- },
770
- base_amount: baseAmount,
771
- discount,
772
- subtotal: Math.max(baseAmount - discount, 0),
773
- tax: 0,
774
- total
1648
+ item: { id: sku },
1649
+ quantity: item.quantity,
1650
+ ...name ? { name } : {},
1651
+ ...product?.description ? { description: product.description } : {},
1652
+ ...images.length > 0 ? { images } : {},
1653
+ unit_amount: toMinorUnits(item.price.unitPrice),
1654
+ ...product ? { product_id: product.identifier.key } : {},
1655
+ sku,
1656
+ ...variant && variant.options.length > 0 ? { variant_options: variant.options.map((option) => ({ name: option.name, value: option.value.label })) } : {},
1657
+ totals: [
1658
+ { type: "items_base_amount", display_text: "Base Amount", amount: baseAmount },
1659
+ ...discount > 0 ? [{ type: "discount", display_text: "Discount", amount: discount }] : [],
1660
+ { type: "subtotal", display_text: "Subtotal", amount: Math.max(baseAmount - discount, 0) },
1661
+ { type: "total", display_text: "Total", amount: toMinorUnits(item.price.totalPrice) }
1662
+ ]
775
1663
  };
776
1664
  }
777
1665
  function toACPTotals(price) {
778
- const base = toMinorUnits(price.totalProductPrice.value);
779
- const discount = toMinorUnits(price.totalDiscount.value);
1666
+ const base = toMinorUnits(price.totalProductPrice);
1667
+ const discount = Math.abs(toMinorUnits(price.totalDiscount));
780
1668
  const subtotal = Math.max(base - discount, 0);
781
- const fulfillment = toMinorUnits(price.totalShipping.value);
782
- const tax = toMinorUnits(price.totalTax.value);
783
- const total = toMinorUnits(price.grandTotal.value);
1669
+ const fulfillment = toMinorUnits(price.totalShipping);
1670
+ const tax = toMinorUnits(price.totalTax);
1671
+ const total = toMinorUnits(price.grandTotal);
784
1672
  return [
785
1673
  {
786
1674
  type: "items_base_amount",
@@ -814,21 +1702,33 @@ function toACPTotals(price) {
814
1702
  }
815
1703
  ];
816
1704
  }
817
- function toACPFulfillmentOption(method) {
818
- const amount = toMinorUnits(method.price.value);
1705
+ function toACPFulfillmentOption(method, title, classification) {
1706
+ const description = method.deliveryTime || method.description;
1707
+ const { type, ...details } = classification;
819
1708
  return {
820
- type: "shipping",
1709
+ type,
821
1710
  id: method.identifier.key,
822
- title: method.name,
823
- subtitle: method.deliveryTime,
824
- carrier: method.carrier ?? "",
825
- earliest_delivery_time: (/* @__PURE__ */ new Date()).toISOString(),
826
- latest_delivery_time: (/* @__PURE__ */ new Date()).toISOString(),
827
- subtotal: amount,
828
- tax: 0,
829
- total: amount
1711
+ title,
1712
+ ...description ? { description } : {},
1713
+ // Carriers belong to shipped options only.
1714
+ ...type === "shipping" && method.carrier ? { carrier: method.carrier } : {},
1715
+ ...details,
1716
+ totals: [{ type: "total", display_text: title, amount: toMinorUnits(method.price) }]
830
1717
  };
831
1718
  }
1719
+ function getOptionTitles(options) {
1720
+ const titles = /* @__PURE__ */ new Map();
1721
+ const used = /* @__PURE__ */ new Set();
1722
+ for (const option of options) {
1723
+ let title = option.name || option.identifier.key;
1724
+ if (used.has(title)) {
1725
+ title = `${title} (${option.identifier.key})`;
1726
+ }
1727
+ used.add(title);
1728
+ titles.set(option.identifier.key, title);
1729
+ }
1730
+ return titles;
1731
+ }
832
1732
  function toReactionaryAddress(address) {
833
1733
  const [firstName, ...lastNameParts] = address.name.split(" ");
834
1734
  return {
@@ -842,34 +1742,35 @@ function toReactionaryAddress(address) {
842
1742
  countryCode: address.country
843
1743
  };
844
1744
  }
845
- function toPaymentMethodIdentifier(provider) {
846
- return {
847
- method: "card",
848
- name: provider,
849
- paymentProcessor: provider
850
- };
851
- }
852
- function toMinorUnits(value) {
853
- return Math.max(Math.round(value * 100), 0);
854
- }
855
- function createFeedStream(chunks) {
856
- const encoder = new TextEncoder();
857
- const iterator = chunks[Symbol.asyncIterator]();
858
- return new ReadableStream({
859
- async pull(controller) {
860
- const next = await iterator.next();
861
- if (next.done) {
862
- controller.close();
863
- return;
864
- }
865
- controller.enqueue(
866
- typeof next.value === "string" ? encoder.encode(next.value) : next.value
867
- );
868
- },
869
- async cancel() {
870
- await iterator.return?.();
871
- }
872
- });
1745
+ const CURRENCY_EXPONENTS = {
1746
+ BIF: 0,
1747
+ CLP: 0,
1748
+ DJF: 0,
1749
+ GNF: 0,
1750
+ ISK: 0,
1751
+ JPY: 0,
1752
+ KMF: 0,
1753
+ KRW: 0,
1754
+ PYG: 0,
1755
+ RWF: 0,
1756
+ UGX: 0,
1757
+ UYI: 0,
1758
+ VND: 0,
1759
+ VUV: 0,
1760
+ XAF: 0,
1761
+ XOF: 0,
1762
+ XPF: 0,
1763
+ BHD: 3,
1764
+ IQD: 3,
1765
+ JOD: 3,
1766
+ KWD: 3,
1767
+ LYD: 3,
1768
+ OMR: 3,
1769
+ TND: 3
1770
+ };
1771
+ function toMinorUnits(amount) {
1772
+ const exponent = CURRENCY_EXPONENTS[amount.currency.toUpperCase()] ?? 2;
1773
+ return Math.round(amount.value * 10 ** exponent);
873
1774
  }
874
1775
  async function toWebRequest(request) {
875
1776
  const headers = toWebHeaders(request.headers);
@@ -926,10 +1827,12 @@ async function sendWebResponse(response, webResponse) {
926
1827
  }
927
1828
  function getAcpOperationPath(request, basePath) {
928
1829
  const pathname = getProtocolPathname(request, basePath);
929
- return pathname.replace(/^\/checkout_sessions\/[^/]+/, "/checkout_sessions/{id}").replace(/^\/product_feeds\/[^/]+/, "/product_feeds/{id}");
1830
+ return pathname.replace(/^\/checkout_sessions\/[^/]+/, "/checkout_sessions/{id}").replace(/^\/carts\/[^/]+/, "/carts/{id}");
930
1831
  }
931
1832
  export {
1833
+ ACP_API_VERSION,
932
1834
  DEFAULT_ACP_PAYMENT_AUTHORIZATION_WAIT,
933
1835
  DEFAULT_ACP_PLACEHOLDER_EMAIL,
934
- ReactionaryACPServer
1836
+ ReactionaryACPServer,
1837
+ createBearerTokenAuthenticator
935
1838
  };