@reactionary/acp 0.10.42 → 0.10.43
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -57
- package/index.js +11 -0
- package/lib/acp-discounts.js +113 -0
- package/lib/acp-idempotency.js +119 -0
- package/lib/acp-mcp.js +206 -0
- package/lib/acp-payment-handlers.js +38 -0
- package/lib/acp-schemas.js +240 -70
- package/lib/acp-webhooks.js +72 -0
- package/lib/reactionary-acp-server.js +1156 -253
- package/package.json +3 -3
- package/src/index.d.ts +4 -0
- package/src/lib/acp-discounts.d.ts +45 -0
- package/src/lib/acp-idempotency.d.ts +23 -0
- package/src/lib/acp-mcp.d.ts +21 -0
- package/src/lib/acp-payment-handlers.d.ts +64 -0
- package/src/lib/acp-schemas.d.ts +766 -209
- package/src/lib/acp-webhooks.d.ts +48 -0
- package/src/lib/reactionary-acp-server.d.ts +343 -39
|
@@ -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
|
|
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
|
-
|
|
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
|
|
61
|
-
if (
|
|
62
|
-
requestContext.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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
"
|
|
436
|
+
"POST /carts",
|
|
437
|
+
"GET /carts/{id}",
|
|
438
|
+
"PUT /carts/{id}",
|
|
439
|
+
"POST /carts/{id}/cancel"
|
|
204
440
|
]
|
|
205
441
|
};
|
|
206
442
|
}
|
|
207
|
-
async
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
|
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 =
|
|
342
|
-
let current = {
|
|
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
|
|
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: "
|
|
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
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
|
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
|
|
402
|
-
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:
|
|
425
|
-
notificationEmail: state
|
|
426
|
-
notificationPhone: state
|
|
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:
|
|
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:
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
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 (
|
|
469
|
-
return { cart, price: cart.price, options: [],
|
|
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(
|
|
475
|
-
notificationEmail: state
|
|
476
|
-
notificationPhone: state
|
|
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
|
|
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
|
|
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
|
-
|
|
514
|
-
...persisted.
|
|
515
|
-
|
|
516
|
-
...persisted.
|
|
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
|
-
|
|
526
|
-
|
|
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
|
|
1217
|
+
options,
|
|
1218
|
+
status,
|
|
1219
|
+
...orderId ? { orderId } : {}
|
|
538
1220
|
};
|
|
539
1221
|
}
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
const
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
|
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
|
-
|
|
665
|
-
|
|
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
|
-
|
|
686
|
-
} catch
|
|
1525
|
+
body = await request.json();
|
|
1526
|
+
} catch {
|
|
687
1527
|
throw new ACPHttpError(400, {
|
|
688
1528
|
type: "invalid_request",
|
|
689
|
-
code: "
|
|
690
|
-
message:
|
|
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
|
|
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:
|
|
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
|
-
["
|
|
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 (
|
|
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: "
|
|
751
|
-
code: "
|
|
752
|
-
message:
|
|
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
|
-
|
|
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
|
|
762
|
-
const
|
|
763
|
-
const
|
|
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
|
-
|
|
768
|
-
|
|
769
|
-
},
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
|
779
|
-
const discount = toMinorUnits(price.totalDiscount
|
|
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
|
|
782
|
-
const tax = toMinorUnits(price.totalTax
|
|
783
|
-
const total = toMinorUnits(price.grandTotal
|
|
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
|
|
1705
|
+
function toACPFulfillmentOption(method, title, classification) {
|
|
1706
|
+
const description = method.deliveryTime || method.description;
|
|
1707
|
+
const { type, ...details } = classification;
|
|
819
1708
|
return {
|
|
820
|
-
type
|
|
1709
|
+
type,
|
|
821
1710
|
id: method.identifier.key,
|
|
822
|
-
title
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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(/^\/
|
|
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
|
};
|