@reactionary/acp 0.0.0-stage → 0.0.1
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 +156 -2
- package/index.js +2 -0
- package/lib/acp-schemas.js +123 -0
- package/lib/reactionary-acp-server.js +815 -0
- package/package.json +18 -4
- package/src/index.d.ts +2 -0
- package/src/lib/acp-schemas.d.ts +303 -0
- package/src/lib/reactionary-acp-server.d.ts +100 -0
|
@@ -0,0 +1,815 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createInitialRequestContext,
|
|
3
|
+
MemoryCache,
|
|
4
|
+
SessionSchema
|
|
5
|
+
} from "@reactionary/core";
|
|
6
|
+
import {
|
|
7
|
+
ReactionaryFeedGenerator,
|
|
8
|
+
acpProductFeedTransformer
|
|
9
|
+
} from "@reactionary/feeds";
|
|
10
|
+
import {
|
|
11
|
+
ACPCheckoutSessionStateSchema,
|
|
12
|
+
ACPCompleteCheckoutSessionRequestSchema,
|
|
13
|
+
ACPCreateCheckoutSessionRequestSchema,
|
|
14
|
+
ACPUpdateCheckoutSessionRequestSchema
|
|
15
|
+
} from "./acp-schemas.js";
|
|
16
|
+
const ACP_SESSION_ID_HEADER = "acp-session-id";
|
|
17
|
+
const SESSION_CACHE_KEY_PREFIX = "reactionary:acp:session";
|
|
18
|
+
const CHECKOUT_SESSION_CACHE_KEY_PREFIX = "reactionary:acp:checkout-session";
|
|
19
|
+
class ReactionaryACPServer {
|
|
20
|
+
constructor(clientFactory, options = {}) {
|
|
21
|
+
this.clientFactory = clientFactory;
|
|
22
|
+
this.options = options;
|
|
23
|
+
this.sessionStore = new ReactionaryACPSessionStore(
|
|
24
|
+
this.options.sessionCache ?? new MemoryCache(),
|
|
25
|
+
this.options.sessionTtlSeconds ?? 60 * 60 * 24
|
|
26
|
+
);
|
|
27
|
+
this.checkoutSessionStore = new ReactionaryACPCheckoutSessionStore(
|
|
28
|
+
this.options.sessionCache ?? new MemoryCache(),
|
|
29
|
+
this.options.checkoutSessionTtlSeconds ?? 60 * 60 * 24
|
|
30
|
+
);
|
|
31
|
+
assertACPClient(this.clientFactory(createInitialRequestContext()));
|
|
32
|
+
}
|
|
33
|
+
clientFactory;
|
|
34
|
+
options;
|
|
35
|
+
sessionStore;
|
|
36
|
+
checkoutSessionStore;
|
|
37
|
+
async fetch(request) {
|
|
38
|
+
const sessionId = getOrCreateSessionId(request);
|
|
39
|
+
const requestContext = await this.createRequestContext(sessionId);
|
|
40
|
+
const requestedFeed = this.getRequestedProductFeed(request);
|
|
41
|
+
if (requestedFeed) {
|
|
42
|
+
requestContext.languageContext = requestedFeed.feed.languageContext;
|
|
43
|
+
}
|
|
44
|
+
const client = this.clientFactory(requestContext);
|
|
45
|
+
assertACPClient(client);
|
|
46
|
+
const response = await this.handleRequest(request, client, requestContext).catch((error) => toACPErrorResponse(error));
|
|
47
|
+
await this.sessionStore.put(sessionId, requestContext.session);
|
|
48
|
+
response.headers.set(ACP_SESSION_ID_HEADER, sessionId);
|
|
49
|
+
return response;
|
|
50
|
+
}
|
|
51
|
+
getHandler() {
|
|
52
|
+
return {
|
|
53
|
+
fetch: (request) => this.fetch(request),
|
|
54
|
+
close: () => this.close()
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
toNodeHandler() {
|
|
58
|
+
return async (request, response) => {
|
|
59
|
+
const webResponse = await this.fetch(await toWebRequest(request));
|
|
60
|
+
await sendWebResponse(response, webResponse);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
close() {
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
async createRequestContext(sessionId) {
|
|
67
|
+
const restoredSession = await this.sessionStore.get(sessionId);
|
|
68
|
+
const requestContext = createInitialRequestContext();
|
|
69
|
+
if (restoredSession) {
|
|
70
|
+
requestContext.session = restoredSession;
|
|
71
|
+
}
|
|
72
|
+
return requestContext;
|
|
73
|
+
}
|
|
74
|
+
async handleRequest(request, client, requestContext) {
|
|
75
|
+
if (request.method === "OPTIONS") {
|
|
76
|
+
return new Response(null, {
|
|
77
|
+
status: 204,
|
|
78
|
+
headers: {
|
|
79
|
+
allow: "GET, HEAD, OPTIONS, POST"
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
84
|
+
const productFeedId = getProductFeedId(request, this.options.basePath);
|
|
85
|
+
if (productFeedId) {
|
|
86
|
+
const feed = this.getProductFeedDefinition(productFeedId);
|
|
87
|
+
if (!feed) {
|
|
88
|
+
return acpErrorResponse(404, {
|
|
89
|
+
type: "invalid_request",
|
|
90
|
+
code: "missing",
|
|
91
|
+
message: `Product feed not found: ${productFeedId}`
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return this.getProductFeed(
|
|
95
|
+
request,
|
|
96
|
+
productFeedId,
|
|
97
|
+
feed,
|
|
98
|
+
client,
|
|
99
|
+
requestContext,
|
|
100
|
+
request.method === "HEAD"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
const checkoutSessionId = getCheckoutSessionId(
|
|
104
|
+
request,
|
|
105
|
+
this.options.basePath
|
|
106
|
+
);
|
|
107
|
+
if (checkoutSessionId) {
|
|
108
|
+
return this.getCheckoutSession(
|
|
109
|
+
checkoutSessionId,
|
|
110
|
+
client,
|
|
111
|
+
request.method === "HEAD"
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return jsonResponse(this.getReadinessDocument(), {
|
|
115
|
+
omitBody: request.method === "HEAD"
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
if (request.method === "POST") {
|
|
119
|
+
return this.handlePost(request, client, requestContext);
|
|
120
|
+
}
|
|
121
|
+
return jsonResponse({
|
|
122
|
+
error: {
|
|
123
|
+
code: "METHOD_NOT_ALLOWED",
|
|
124
|
+
message: `Unsupported method: ${request.method}`
|
|
125
|
+
}
|
|
126
|
+
}, {
|
|
127
|
+
status: 405,
|
|
128
|
+
headers: {
|
|
129
|
+
allow: "GET, HEAD, OPTIONS, POST"
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
getReadinessDocument() {
|
|
134
|
+
return {
|
|
135
|
+
name: this.options.name ?? "@reactionary/acp",
|
|
136
|
+
version: this.options.version ?? "0.0.1",
|
|
137
|
+
protocol: "acp",
|
|
138
|
+
status: "ready",
|
|
139
|
+
actions: [
|
|
140
|
+
"POST /checkout_sessions",
|
|
141
|
+
"POST /checkout_sessions/{checkout_session_id}",
|
|
142
|
+
"GET /checkout_sessions/{checkout_session_id}",
|
|
143
|
+
"POST /checkout_sessions/{checkout_session_id}/complete",
|
|
144
|
+
"POST /checkout_sessions/{checkout_session_id}/cancel",
|
|
145
|
+
"GET /product_feeds/{id}/products"
|
|
146
|
+
]
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
async getProductFeed(request, feedId, feed, client, requestContext, omitBody) {
|
|
150
|
+
const format = new URL(request.url).searchParams.get("format") ?? "json";
|
|
151
|
+
if (format !== "json" && format !== "jsonl") {
|
|
152
|
+
return acpErrorResponse(400, {
|
|
153
|
+
type: "invalid_request",
|
|
154
|
+
code: "invalid",
|
|
155
|
+
message: `Unsupported product feed format: ${format}`,
|
|
156
|
+
param: "$.format"
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const generator = new ReactionaryFeedGenerator(client, {
|
|
160
|
+
defaultFulfillmentCenterKeys: this.options.productFeed?.defaultFulfillmentCenterKeys,
|
|
161
|
+
productConcurrency: this.options.productFeed?.productConcurrency
|
|
162
|
+
});
|
|
163
|
+
const output = acpProductFeedTransformer.transform(
|
|
164
|
+
generator.products(feed, requestContext),
|
|
165
|
+
{
|
|
166
|
+
feedId,
|
|
167
|
+
feed,
|
|
168
|
+
options: { format }
|
|
169
|
+
}
|
|
170
|
+
);
|
|
171
|
+
const extension = format === "jsonl" ? "jsonl" : "json";
|
|
172
|
+
return new Response(
|
|
173
|
+
omitBody ? null : createFeedStream(output),
|
|
174
|
+
{
|
|
175
|
+
headers: {
|
|
176
|
+
"content-type": format === "jsonl" ? "application/x-ndjson; charset=utf-8" : "application/json; charset=utf-8",
|
|
177
|
+
"content-disposition": `attachment; filename="${feedId}.products.${extension}"`
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
getRequestedProductFeed(request) {
|
|
183
|
+
const id = getProductFeedId(request, this.options.basePath);
|
|
184
|
+
const feed = id ? this.getProductFeedDefinition(id) : void 0;
|
|
185
|
+
return id && feed ? { id, feed } : void 0;
|
|
186
|
+
}
|
|
187
|
+
getProductFeedDefinition(feedId) {
|
|
188
|
+
return this.options.productFeed?.feeds[feedId];
|
|
189
|
+
}
|
|
190
|
+
async handlePost(request, client, requestContext) {
|
|
191
|
+
const checkoutSessionId = getCheckoutSessionId(
|
|
192
|
+
request,
|
|
193
|
+
this.options.basePath
|
|
194
|
+
);
|
|
195
|
+
if (!checkoutSessionId) {
|
|
196
|
+
return this.createCheckoutSession(
|
|
197
|
+
await parseJsonBody(request, ACPCreateCheckoutSessionRequestSchema),
|
|
198
|
+
client,
|
|
199
|
+
requestContext
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (isCheckoutSessionCompleteRequest(request, this.options.basePath)) {
|
|
203
|
+
return this.completeCheckoutSession(
|
|
204
|
+
checkoutSessionId,
|
|
205
|
+
await parseJsonBody(request, ACPCompleteCheckoutSessionRequestSchema),
|
|
206
|
+
client
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
if (isCheckoutSessionCancelRequest(request, this.options.basePath)) {
|
|
210
|
+
return this.cancelCheckoutSession(checkoutSessionId, client);
|
|
211
|
+
}
|
|
212
|
+
return this.updateCheckoutSession(
|
|
213
|
+
checkoutSessionId,
|
|
214
|
+
await parseJsonBody(request, ACPUpdateCheckoutSessionRequestSchema),
|
|
215
|
+
client,
|
|
216
|
+
requestContext
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
async createCheckoutSession(input, client, requestContext) {
|
|
220
|
+
const cart = await this.createCartForItems(input.items, client);
|
|
221
|
+
const checkout = await unwrapACPResult(
|
|
222
|
+
client.checkout.initiateCheckoutForCart({
|
|
223
|
+
cart,
|
|
224
|
+
billingAddress: input.fulfillment_address ? toReactionaryAddress(input.fulfillment_address) : void 0,
|
|
225
|
+
notificationEmail: input.buyer?.email,
|
|
226
|
+
notificationPhone: input.buyer?.phone_number
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
const state = {
|
|
230
|
+
id: `checkout_session_${crypto.randomUUID()}`,
|
|
231
|
+
cartId: cart.identifier.key,
|
|
232
|
+
checkoutId: checkout.identifier.key,
|
|
233
|
+
status: getCheckoutStatus(checkout),
|
|
234
|
+
buyer: input.buyer,
|
|
235
|
+
fulfillmentAddress: input.fulfillment_address
|
|
236
|
+
};
|
|
237
|
+
await this.checkoutSessionStore.put(state.id, state);
|
|
238
|
+
return jsonResponse(
|
|
239
|
+
await this.toACPCheckoutSession(state, checkout, client, requestContext),
|
|
240
|
+
{ status: 201 }
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
async updateCheckoutSession(checkoutSessionId, input, client, requestContext) {
|
|
244
|
+
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
245
|
+
let checkout = await this.getReactionaryCheckout(state, client);
|
|
246
|
+
let cartId = state.cartId;
|
|
247
|
+
if (input.items) {
|
|
248
|
+
const cart = await this.createCartForItems(input.items, client);
|
|
249
|
+
checkout = await unwrapACPResult(
|
|
250
|
+
client.checkout.initiateCheckoutForCart({
|
|
251
|
+
cart,
|
|
252
|
+
billingAddress: input.fulfillment_address ? toReactionaryAddress(input.fulfillment_address) : state.fulfillmentAddress ? toReactionaryAddress(state.fulfillmentAddress) : void 0,
|
|
253
|
+
notificationEmail: input.buyer?.email ?? state.buyer?.email,
|
|
254
|
+
notificationPhone: input.buyer?.phone_number ?? state.buyer?.phone_number
|
|
255
|
+
})
|
|
256
|
+
);
|
|
257
|
+
cartId = cart.identifier.key;
|
|
258
|
+
}
|
|
259
|
+
if (input.fulfillment_address) {
|
|
260
|
+
checkout = await unwrapACPResult(
|
|
261
|
+
client.checkout.setShippingAddress({
|
|
262
|
+
checkout: checkout.identifier,
|
|
263
|
+
shippingAddress: toReactionaryAddress(input.fulfillment_address)
|
|
264
|
+
})
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (input.fulfillment_option_id) {
|
|
268
|
+
checkout = await unwrapACPResult(
|
|
269
|
+
client.checkout.setShippingInstruction({
|
|
270
|
+
checkout: checkout.identifier,
|
|
271
|
+
shippingInstruction: {
|
|
272
|
+
shippingMethod: { key: input.fulfillment_option_id },
|
|
273
|
+
pickupPoint: "",
|
|
274
|
+
instructions: "",
|
|
275
|
+
consentForUnattendedDelivery: false
|
|
276
|
+
}
|
|
277
|
+
})
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
const updatedState = {
|
|
281
|
+
...state,
|
|
282
|
+
cartId,
|
|
283
|
+
checkoutId: checkout.identifier.key,
|
|
284
|
+
status: getCheckoutStatus(checkout),
|
|
285
|
+
buyer: input.buyer ?? state.buyer,
|
|
286
|
+
fulfillmentAddress: input.fulfillment_address ?? state.fulfillmentAddress,
|
|
287
|
+
fulfillmentOptionId: input.fulfillment_option_id ?? state.fulfillmentOptionId
|
|
288
|
+
};
|
|
289
|
+
await this.checkoutSessionStore.put(checkoutSessionId, updatedState);
|
|
290
|
+
return jsonResponse(
|
|
291
|
+
await this.toACPCheckoutSession(
|
|
292
|
+
updatedState,
|
|
293
|
+
checkout,
|
|
294
|
+
client,
|
|
295
|
+
requestContext
|
|
296
|
+
)
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
async getCheckoutSession(checkoutSessionId, client, omitBody) {
|
|
300
|
+
const state = await this.checkoutSessionStore.get(checkoutSessionId);
|
|
301
|
+
if (!state) {
|
|
302
|
+
return acpErrorResponse(404, {
|
|
303
|
+
type: "invalid_request",
|
|
304
|
+
code: "missing",
|
|
305
|
+
message: `Checkout session not found: ${checkoutSessionId}`
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const checkout = await this.getReactionaryCheckout(state, client);
|
|
309
|
+
return jsonResponse(
|
|
310
|
+
await this.toACPCheckoutSession(state, checkout, client),
|
|
311
|
+
{ omitBody }
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
async completeCheckoutSession(checkoutSessionId, input, client) {
|
|
315
|
+
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
316
|
+
if (state.status === "canceled") {
|
|
317
|
+
return acpErrorResponse(405, {
|
|
318
|
+
type: "invalid_request",
|
|
319
|
+
code: "invalid",
|
|
320
|
+
message: "Canceled checkout sessions cannot be completed."
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
let checkout = await this.getReactionaryCheckout(state, client);
|
|
324
|
+
checkout = await unwrapACPResult(
|
|
325
|
+
client.checkout.addPaymentInstruction({
|
|
326
|
+
checkout: checkout.identifier,
|
|
327
|
+
paymentInstruction: {
|
|
328
|
+
amount: checkout.price.grandTotal,
|
|
329
|
+
paymentMethod: toPaymentMethodIdentifier(input.payment_data.provider),
|
|
330
|
+
protocolData: [
|
|
331
|
+
{
|
|
332
|
+
key: "delegated_payment_token",
|
|
333
|
+
value: input.payment_data.token
|
|
334
|
+
}
|
|
335
|
+
]
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
);
|
|
339
|
+
checkout = await unwrapACPResult(
|
|
340
|
+
client.checkout.finalizeCheckout({
|
|
341
|
+
checkout: checkout.identifier
|
|
342
|
+
})
|
|
343
|
+
);
|
|
344
|
+
const completedState = {
|
|
345
|
+
...state,
|
|
346
|
+
status: "completed",
|
|
347
|
+
buyer: input.buyer ?? state.buyer,
|
|
348
|
+
orderId: checkout.resultingOrder?.key
|
|
349
|
+
};
|
|
350
|
+
await this.checkoutSessionStore.put(checkoutSessionId, completedState);
|
|
351
|
+
return jsonResponse(
|
|
352
|
+
await this.toACPCheckoutSession(completedState, checkout, client)
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
async cancelCheckoutSession(checkoutSessionId, client) {
|
|
356
|
+
const state = await this.getRequiredCheckoutSessionState(checkoutSessionId);
|
|
357
|
+
if (state.status === "completed" || state.status === "canceled") {
|
|
358
|
+
return acpErrorResponse(405, {
|
|
359
|
+
type: "invalid_request",
|
|
360
|
+
code: "invalid",
|
|
361
|
+
message: "Completed or canceled checkout sessions cannot be canceled."
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const canceledState = {
|
|
365
|
+
...state,
|
|
366
|
+
status: "canceled"
|
|
367
|
+
};
|
|
368
|
+
const checkout = await this.getReactionaryCheckout(canceledState, client);
|
|
369
|
+
await this.checkoutSessionStore.put(checkoutSessionId, canceledState);
|
|
370
|
+
return jsonResponse(
|
|
371
|
+
await this.toACPCheckoutSession(canceledState, checkout, client)
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
async createCartForItems(items, client) {
|
|
375
|
+
let cart = await unwrapACPResult(client.cart.createCart({}));
|
|
376
|
+
for (const item of items) {
|
|
377
|
+
cart = await unwrapACPResult(
|
|
378
|
+
client.cart.add({
|
|
379
|
+
cart: cart.identifier,
|
|
380
|
+
variant: { sku: item.id },
|
|
381
|
+
quantity: item.quantity
|
|
382
|
+
})
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
return cart;
|
|
386
|
+
}
|
|
387
|
+
async getRequiredCheckoutSessionState(checkoutSessionId) {
|
|
388
|
+
const state = await this.checkoutSessionStore.get(checkoutSessionId);
|
|
389
|
+
if (!state) {
|
|
390
|
+
throw new ACPHttpError(404, {
|
|
391
|
+
type: "invalid_request",
|
|
392
|
+
code: "missing",
|
|
393
|
+
message: `Checkout session not found: ${checkoutSessionId}`
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
return state;
|
|
397
|
+
}
|
|
398
|
+
async getReactionaryCheckout(state, client) {
|
|
399
|
+
return unwrapACPResult(
|
|
400
|
+
client.checkout.getById({
|
|
401
|
+
identifier: { key: state.checkoutId }
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
async toACPCheckoutSession(state, checkout, client, requestContext = createInitialRequestContext()) {
|
|
406
|
+
const fulfillmentOptions = await this.getFulfillmentOptions(
|
|
407
|
+
checkout,
|
|
408
|
+
client
|
|
409
|
+
);
|
|
410
|
+
return {
|
|
411
|
+
id: state.id,
|
|
412
|
+
...state.buyer ? { buyer: state.buyer } : {},
|
|
413
|
+
payment_provider: await this.getPaymentProvider(checkout, client),
|
|
414
|
+
status: state.status === "completed" || state.status === "canceled" ? state.status : getCheckoutStatus(checkout),
|
|
415
|
+
currency: getCurrency(checkout, requestContext),
|
|
416
|
+
line_items: checkout.items.map(toACPLineItem),
|
|
417
|
+
...state.fulfillmentAddress ? { fulfillment_address: state.fulfillmentAddress } : {},
|
|
418
|
+
fulfillment_options: fulfillmentOptions,
|
|
419
|
+
...state.fulfillmentOptionId ? { fulfillment_option_id: state.fulfillmentOptionId } : checkout.shippingInstruction?.shippingMethod.key ? {
|
|
420
|
+
fulfillment_option_id: checkout.shippingInstruction.shippingMethod.key
|
|
421
|
+
} : {},
|
|
422
|
+
totals: toACPTotals(checkout),
|
|
423
|
+
...state.orderId ? {
|
|
424
|
+
order: {
|
|
425
|
+
id: state.orderId,
|
|
426
|
+
checkout_session_id: state.id,
|
|
427
|
+
permalink_url: ""
|
|
428
|
+
}
|
|
429
|
+
} : {},
|
|
430
|
+
messages: [],
|
|
431
|
+
links: this.options.links ?? []
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
async getFulfillmentOptions(checkout, client) {
|
|
435
|
+
const result = await client.checkout.getAvailableShippingMethods({
|
|
436
|
+
checkout: checkout.identifier
|
|
437
|
+
});
|
|
438
|
+
if (!result.success) {
|
|
439
|
+
return [];
|
|
440
|
+
}
|
|
441
|
+
return result.value.map(toACPFulfillmentOption);
|
|
442
|
+
}
|
|
443
|
+
async getPaymentProvider(checkout, client) {
|
|
444
|
+
if (this.options.paymentProvider) {
|
|
445
|
+
return this.options.paymentProvider;
|
|
446
|
+
}
|
|
447
|
+
const result = await client.checkout.getAvailablePaymentMethods({
|
|
448
|
+
checkout: checkout.identifier
|
|
449
|
+
});
|
|
450
|
+
if (result.success) {
|
|
451
|
+
const supported = result.value.find(
|
|
452
|
+
(method) => ["stripe", "adyen", "braintree"].includes(
|
|
453
|
+
method.identifier.paymentProcessor
|
|
454
|
+
)
|
|
455
|
+
);
|
|
456
|
+
if (supported) {
|
|
457
|
+
return {
|
|
458
|
+
provider: supported.identifier.paymentProcessor,
|
|
459
|
+
supported_payment_methods: ["card"]
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
return {
|
|
464
|
+
provider: "stripe",
|
|
465
|
+
supported_payment_methods: ["card"]
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
class ACPHttpError extends Error {
|
|
470
|
+
constructor(status, body) {
|
|
471
|
+
super(body.message);
|
|
472
|
+
this.status = status;
|
|
473
|
+
this.body = body;
|
|
474
|
+
}
|
|
475
|
+
status;
|
|
476
|
+
body;
|
|
477
|
+
}
|
|
478
|
+
class ReactionaryACPSessionStore {
|
|
479
|
+
constructor(cache, ttlSeconds) {
|
|
480
|
+
this.cache = cache;
|
|
481
|
+
this.ttlSeconds = ttlSeconds;
|
|
482
|
+
}
|
|
483
|
+
cache;
|
|
484
|
+
ttlSeconds;
|
|
485
|
+
async get(sessionId) {
|
|
486
|
+
return await this.cache.get(
|
|
487
|
+
this.getCacheKey(sessionId),
|
|
488
|
+
SessionSchema
|
|
489
|
+
) ?? void 0;
|
|
490
|
+
}
|
|
491
|
+
async put(sessionId, session) {
|
|
492
|
+
await this.cache.invalidate([this.getDependencyId(sessionId)]);
|
|
493
|
+
await this.cache.put(this.getCacheKey(sessionId), session, {
|
|
494
|
+
ttlSeconds: this.ttlSeconds,
|
|
495
|
+
dependencyIds: [this.getDependencyId(sessionId)]
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
getCacheKey(sessionId) {
|
|
499
|
+
return `${SESSION_CACHE_KEY_PREFIX}:${sessionId}`;
|
|
500
|
+
}
|
|
501
|
+
getDependencyId(sessionId) {
|
|
502
|
+
return `${SESSION_CACHE_KEY_PREFIX}:${sessionId}`;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
class ReactionaryACPCheckoutSessionStore {
|
|
506
|
+
constructor(cache, ttlSeconds) {
|
|
507
|
+
this.cache = cache;
|
|
508
|
+
this.ttlSeconds = ttlSeconds;
|
|
509
|
+
}
|
|
510
|
+
cache;
|
|
511
|
+
ttlSeconds;
|
|
512
|
+
async get(checkoutSessionId) {
|
|
513
|
+
return await this.cache.get(
|
|
514
|
+
this.getCacheKey(checkoutSessionId),
|
|
515
|
+
ACPCheckoutSessionStateSchema
|
|
516
|
+
) ?? void 0;
|
|
517
|
+
}
|
|
518
|
+
async put(checkoutSessionId, session) {
|
|
519
|
+
await this.cache.invalidate([this.getDependencyId(checkoutSessionId)]);
|
|
520
|
+
await this.cache.put(this.getCacheKey(checkoutSessionId), session, {
|
|
521
|
+
ttlSeconds: this.ttlSeconds,
|
|
522
|
+
dependencyIds: [this.getDependencyId(checkoutSessionId)]
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
getCacheKey(checkoutSessionId) {
|
|
526
|
+
return `${CHECKOUT_SESSION_CACHE_KEY_PREFIX}:${checkoutSessionId}`;
|
|
527
|
+
}
|
|
528
|
+
getDependencyId(checkoutSessionId) {
|
|
529
|
+
return `${CHECKOUT_SESSION_CACHE_KEY_PREFIX}:${checkoutSessionId}`;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
function getSessionId(request) {
|
|
533
|
+
return request.headers.get(ACP_SESSION_ID_HEADER) ?? void 0;
|
|
534
|
+
}
|
|
535
|
+
function getOrCreateSessionId(request) {
|
|
536
|
+
return getSessionId(request) ?? crypto.randomUUID();
|
|
537
|
+
}
|
|
538
|
+
function getCheckoutSessionId(request, basePath) {
|
|
539
|
+
const pathname = getProtocolPathname(request, basePath);
|
|
540
|
+
const match = /^\/checkout_sessions\/([^/]+)(?:\/(?:complete|cancel))?$/.exec(
|
|
541
|
+
pathname
|
|
542
|
+
);
|
|
543
|
+
return match?.[1];
|
|
544
|
+
}
|
|
545
|
+
function getProductFeedId(request, basePath) {
|
|
546
|
+
const pathname = getProtocolPathname(request, basePath);
|
|
547
|
+
const match = /^\/product_feeds\/([^/]+)\/products$/.exec(pathname);
|
|
548
|
+
return match?.[1];
|
|
549
|
+
}
|
|
550
|
+
function isCheckoutSessionCompleteRequest(request, basePath) {
|
|
551
|
+
return getProtocolPathname(request, basePath).endsWith("/complete");
|
|
552
|
+
}
|
|
553
|
+
function isCheckoutSessionCancelRequest(request, basePath) {
|
|
554
|
+
return getProtocolPathname(request, basePath).endsWith("/cancel");
|
|
555
|
+
}
|
|
556
|
+
function getProtocolPathname(request, basePath = "/acp") {
|
|
557
|
+
const pathname = new URL(request.url).pathname;
|
|
558
|
+
if (pathname === basePath) {
|
|
559
|
+
return "/";
|
|
560
|
+
}
|
|
561
|
+
if (pathname.startsWith(`${basePath}/`)) {
|
|
562
|
+
return pathname.slice(basePath.length);
|
|
563
|
+
}
|
|
564
|
+
return pathname;
|
|
565
|
+
}
|
|
566
|
+
async function parseJsonBody(request, schema) {
|
|
567
|
+
try {
|
|
568
|
+
return schema.parse(await request.json());
|
|
569
|
+
} catch (error) {
|
|
570
|
+
throw new ACPHttpError(400, {
|
|
571
|
+
type: "invalid_request",
|
|
572
|
+
code: "invalid",
|
|
573
|
+
message: error instanceof Error ? error.message : "Invalid request body"
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
function jsonResponse(body, options = {}) {
|
|
578
|
+
const headers = new Headers(options.headers);
|
|
579
|
+
headers.set("content-type", "application/json; charset=utf-8");
|
|
580
|
+
return new Response(
|
|
581
|
+
options.omitBody ? null : JSON.stringify(body),
|
|
582
|
+
{
|
|
583
|
+
status: options.status ?? 200,
|
|
584
|
+
headers
|
|
585
|
+
}
|
|
586
|
+
);
|
|
587
|
+
}
|
|
588
|
+
function acpErrorResponse(status, body) {
|
|
589
|
+
return jsonResponse(body, { status });
|
|
590
|
+
}
|
|
591
|
+
function assertACPClient(client) {
|
|
592
|
+
const missing = getMissingACPClientOperations(client);
|
|
593
|
+
if (missing.length > 0) {
|
|
594
|
+
throw new Error(
|
|
595
|
+
`Reactionary ACP server cannot initialize because the client is missing required operations: ${missing.join(", ")}`
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
function toACPErrorResponse(error) {
|
|
600
|
+
if (error instanceof ACPHttpError) {
|
|
601
|
+
return acpErrorResponse(error.status, error.body);
|
|
602
|
+
}
|
|
603
|
+
return acpErrorResponse(500, {
|
|
604
|
+
type: "processing_error",
|
|
605
|
+
code: "internal_error",
|
|
606
|
+
message: error instanceof Error ? error.message : String(error)
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
function getMissingACPClientOperations(client) {
|
|
610
|
+
return [
|
|
611
|
+
["cart.createCart", client.cart?.createCart],
|
|
612
|
+
["cart.add", client.cart?.add],
|
|
613
|
+
["cart.getById", client.cart?.getById],
|
|
614
|
+
["checkout.initiateCheckoutForCart", client.checkout?.initiateCheckoutForCart],
|
|
615
|
+
["checkout.getById", client.checkout?.getById],
|
|
616
|
+
["checkout.setShippingAddress", client.checkout?.setShippingAddress],
|
|
617
|
+
["checkout.getAvailableShippingMethods", client.checkout?.getAvailableShippingMethods],
|
|
618
|
+
["checkout.getAvailablePaymentMethods", client.checkout?.getAvailablePaymentMethods],
|
|
619
|
+
["checkout.setShippingInstruction", client.checkout?.setShippingInstruction],
|
|
620
|
+
["checkout.addPaymentInstruction", client.checkout?.addPaymentInstruction],
|
|
621
|
+
["checkout.finalizeCheckout", client.checkout?.finalizeCheckout],
|
|
622
|
+
["productSearch.queryByTerm", client.productSearch?.queryByTerm],
|
|
623
|
+
["product.getBySKU", client.product?.getBySKU],
|
|
624
|
+
["price.getListPrice", client.price?.getListPrice],
|
|
625
|
+
["price.getCustomerPrice", client.price?.getCustomerPrice],
|
|
626
|
+
["inventory.getBySKU", client.inventory?.getBySKU]
|
|
627
|
+
].filter(([, operation]) => typeof operation !== "function").map(([name]) => name);
|
|
628
|
+
}
|
|
629
|
+
async function unwrapACPResult(resultPromise) {
|
|
630
|
+
const result = await resultPromise;
|
|
631
|
+
if (!result.success) {
|
|
632
|
+
throw new ACPHttpError(400, {
|
|
633
|
+
type: "processing_error",
|
|
634
|
+
code: "reactionary_error",
|
|
635
|
+
message: JSON.stringify(result.error)
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
return result.value;
|
|
639
|
+
}
|
|
640
|
+
function getCheckoutStatus(checkout) {
|
|
641
|
+
return checkout.readyForFinalization ? "ready_for_payment" : "not_ready_for_payment";
|
|
642
|
+
}
|
|
643
|
+
function getCurrency(checkout, requestContext) {
|
|
644
|
+
return (checkout.price.grandTotal.currency ?? requestContext.languageContext.currencyCode).toLowerCase();
|
|
645
|
+
}
|
|
646
|
+
function toACPLineItem(item) {
|
|
647
|
+
const baseAmount = toMinorUnits(item.price.unitPrice.value * item.quantity);
|
|
648
|
+
const discount = toMinorUnits(item.price.totalDiscount.value);
|
|
649
|
+
const total = toMinorUnits(item.price.totalPrice.value);
|
|
650
|
+
return {
|
|
651
|
+
id: item.identifier.key,
|
|
652
|
+
item: {
|
|
653
|
+
id: item.variant.sku,
|
|
654
|
+
quantity: item.quantity
|
|
655
|
+
},
|
|
656
|
+
base_amount: baseAmount,
|
|
657
|
+
discount,
|
|
658
|
+
subtotal: Math.max(baseAmount - discount, 0),
|
|
659
|
+
tax: 0,
|
|
660
|
+
total
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
function toACPTotals(checkout) {
|
|
664
|
+
const base = toMinorUnits(checkout.price.totalProductPrice.value);
|
|
665
|
+
const discount = toMinorUnits(checkout.price.totalDiscount.value);
|
|
666
|
+
const subtotal = Math.max(base - discount, 0);
|
|
667
|
+
const fulfillment = toMinorUnits(checkout.price.totalShipping.value);
|
|
668
|
+
const tax = toMinorUnits(checkout.price.totalTax.value);
|
|
669
|
+
const total = toMinorUnits(checkout.price.grandTotal.value);
|
|
670
|
+
return [
|
|
671
|
+
{
|
|
672
|
+
type: "items_base_amount",
|
|
673
|
+
display_text: "Item(s) total",
|
|
674
|
+
amount: base
|
|
675
|
+
},
|
|
676
|
+
{
|
|
677
|
+
type: "items_discount",
|
|
678
|
+
display_text: "Item discount",
|
|
679
|
+
amount: discount
|
|
680
|
+
},
|
|
681
|
+
{
|
|
682
|
+
type: "subtotal",
|
|
683
|
+
display_text: "Subtotal",
|
|
684
|
+
amount: subtotal
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
type: "fulfillment",
|
|
688
|
+
display_text: "Fulfillment",
|
|
689
|
+
amount: fulfillment
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
type: "tax",
|
|
693
|
+
display_text: "Tax",
|
|
694
|
+
amount: tax
|
|
695
|
+
},
|
|
696
|
+
{
|
|
697
|
+
type: "total",
|
|
698
|
+
display_text: "Total",
|
|
699
|
+
amount: total
|
|
700
|
+
}
|
|
701
|
+
];
|
|
702
|
+
}
|
|
703
|
+
function toACPFulfillmentOption(method) {
|
|
704
|
+
const amount = toMinorUnits(method.price.value);
|
|
705
|
+
return {
|
|
706
|
+
type: "shipping",
|
|
707
|
+
id: method.identifier.key,
|
|
708
|
+
title: method.name,
|
|
709
|
+
subtitle: method.deliveryTime,
|
|
710
|
+
carrier: method.carrier ?? "",
|
|
711
|
+
earliest_delivery_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
712
|
+
latest_delivery_time: (/* @__PURE__ */ new Date()).toISOString(),
|
|
713
|
+
subtotal: amount,
|
|
714
|
+
tax: 0,
|
|
715
|
+
total: amount
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
function toReactionaryAddress(address) {
|
|
719
|
+
const [firstName, ...lastNameParts] = address.name.split(" ");
|
|
720
|
+
return {
|
|
721
|
+
firstName: firstName ?? address.name,
|
|
722
|
+
lastName: lastNameParts.join(" "),
|
|
723
|
+
streetAddress: address.line_one,
|
|
724
|
+
streetNumber: "",
|
|
725
|
+
city: address.city,
|
|
726
|
+
region: address.state,
|
|
727
|
+
postalCode: address.postal_code,
|
|
728
|
+
countryCode: address.country
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
function toPaymentMethodIdentifier(provider) {
|
|
732
|
+
return {
|
|
733
|
+
method: "card",
|
|
734
|
+
name: provider,
|
|
735
|
+
paymentProcessor: provider
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
function toMinorUnits(value) {
|
|
739
|
+
return Math.max(Math.round(value * 100), 0);
|
|
740
|
+
}
|
|
741
|
+
function createFeedStream(chunks) {
|
|
742
|
+
const encoder = new TextEncoder();
|
|
743
|
+
const iterator = chunks[Symbol.asyncIterator]();
|
|
744
|
+
return new ReadableStream({
|
|
745
|
+
async pull(controller) {
|
|
746
|
+
const next = await iterator.next();
|
|
747
|
+
if (next.done) {
|
|
748
|
+
controller.close();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
controller.enqueue(
|
|
752
|
+
typeof next.value === "string" ? encoder.encode(next.value) : next.value
|
|
753
|
+
);
|
|
754
|
+
},
|
|
755
|
+
async cancel() {
|
|
756
|
+
await iterator.return?.();
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
async function toWebRequest(request) {
|
|
761
|
+
const headers = toWebHeaders(request.headers);
|
|
762
|
+
const url = new URL(
|
|
763
|
+
request.url ?? "/",
|
|
764
|
+
`http://${request.headers.host ?? "localhost"}`
|
|
765
|
+
);
|
|
766
|
+
const body = await readNodeRequestBody(request);
|
|
767
|
+
return new Request(url, {
|
|
768
|
+
method: request.method,
|
|
769
|
+
headers,
|
|
770
|
+
body
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
function toWebHeaders(headers) {
|
|
774
|
+
const webHeaders = new Headers();
|
|
775
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
776
|
+
if (value === void 0) {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
if (Array.isArray(value)) {
|
|
780
|
+
for (const entry of value) {
|
|
781
|
+
webHeaders.append(key, entry);
|
|
782
|
+
}
|
|
783
|
+
continue;
|
|
784
|
+
}
|
|
785
|
+
webHeaders.set(key, value);
|
|
786
|
+
}
|
|
787
|
+
return webHeaders;
|
|
788
|
+
}
|
|
789
|
+
async function readNodeRequestBody(request) {
|
|
790
|
+
if (request.method === "GET" || request.method === "HEAD") {
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
const chunks = [];
|
|
794
|
+
for await (const chunk of request) {
|
|
795
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
796
|
+
}
|
|
797
|
+
if (chunks.length === 0) {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
return Buffer.concat(chunks);
|
|
801
|
+
}
|
|
802
|
+
async function sendWebResponse(response, webResponse) {
|
|
803
|
+
response.writeHead(
|
|
804
|
+
webResponse.status,
|
|
805
|
+
Object.fromEntries(webResponse.headers.entries())
|
|
806
|
+
);
|
|
807
|
+
if (webResponse.body) {
|
|
808
|
+
response.end(Buffer.from(await webResponse.arrayBuffer()));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
response.end();
|
|
812
|
+
}
|
|
813
|
+
export {
|
|
814
|
+
ReactionaryACPServer
|
|
815
|
+
};
|