@reactionary/acp 0.10.42 → 0.10.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,41 @@
1
+ import { CurrencySchema } from "@reactionary/core";
1
2
  import * as z from "zod";
3
+ import { ACPDiscountsRequestSchema, ACPRejectedDiscountSchema } from "./acp-discounts.js";
2
4
  const ACPItemSchema = z.object({
3
5
  id: z.string().min(1),
4
- quantity: z.int().min(1)
6
+ quantity: z.number().positive().optional(),
7
+ name: z.string().optional(),
8
+ unit_amount: z.int().optional()
5
9
  });
6
- const ACPBuyerSchema = z.object({
10
+ const ACPCompanyInfoSchema = z.object({
7
11
  name: z.string().min(1),
12
+ tax_id: z.string().optional(),
13
+ department: z.string().optional(),
14
+ cost_center: z.string().optional()
15
+ });
16
+ const ACPLoyaltyInfoSchema = z.object({
17
+ tier: z.string().optional(),
18
+ points_balance: z.int().optional(),
19
+ member_since: z.string().optional()
20
+ });
21
+ const ACPTaxExemptionSchema = z.object({
22
+ certificate_id: z.string().min(1),
23
+ certificate_type: z.enum(["resale", "exempt_organization", "government"]),
24
+ exempt_regions: z.array(z.string()).optional(),
25
+ expires_at: z.string().optional()
26
+ });
27
+ const ACPBuyerSchema = z.object({
28
+ first_name: z.string().optional(),
29
+ last_name: z.string().optional(),
30
+ full_name: z.string().optional(),
8
31
  email: z.email(),
9
- phone_number: z.string().optional()
32
+ phone_number: z.string().optional(),
33
+ customer_id: z.string().optional(),
34
+ account_type: z.enum(["guest", "registered", "business"]).optional(),
35
+ authentication_status: z.enum(["authenticated", "guest", "requires_signin"]).optional(),
36
+ company: ACPCompanyInfoSchema.optional(),
37
+ loyalty: ACPLoyaltyInfoSchema.optional(),
38
+ tax_exemption: ACPTaxExemptionSchema.optional()
10
39
  });
11
40
  const ACPAddressSchema = z.object({
12
41
  name: z.string().min(1),
@@ -15,113 +44,254 @@ const ACPAddressSchema = z.object({
15
44
  city: z.string().min(1),
16
45
  state: z.string().min(1),
17
46
  country: z.string().min(1),
18
- postal_code: z.string().min(1),
19
- phone_number: z.string().optional()
47
+ postal_code: z.string().min(1)
48
+ });
49
+ const ACPFulfillmentDetailsSchema = z.object({
50
+ name: z.string().optional(),
51
+ phone_number: z.string().optional(),
52
+ email: z.email().optional(),
53
+ address: ACPAddressSchema.optional()
54
+ });
55
+ const ACPSelectedFulfillmentOptionSchema = z.object({
56
+ type: z.enum(["shipping", "digital", "pickup", "local_delivery"]),
57
+ option_id: z.string().min(1),
58
+ item_ids: z.array(z.string())
59
+ });
60
+ const ACPFlatMetadataSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).refine((metadata) => Object.keys(metadata).length <= 20, { error: "metadata may hold at most 20 keys" }).refine((metadata) => JSON.stringify(metadata).length <= 4096, { error: "metadata may be at most 4KB" });
61
+ const ACPAffiliateAttributionSchema = z.object({
62
+ provider: z.string().min(1),
63
+ token: z.string().optional(),
64
+ publisher_id: z.string().optional(),
65
+ campaign_id: z.string().optional(),
66
+ creative_id: z.string().optional(),
67
+ sub_id: z.string().optional(),
68
+ source: z.object({
69
+ type: z.enum(["url", "platform", "unknown"]),
70
+ url: z.url().optional()
71
+ }).optional(),
72
+ issued_at: z.iso.datetime({ offset: true }).optional(),
73
+ expires_at: z.iso.datetime({ offset: true }).optional(),
74
+ metadata: ACPFlatMetadataSchema.optional(),
75
+ touchpoint: z.enum(["first", "last"]).optional()
76
+ }).refine(
77
+ (attribution) => attribution.token !== void 0 || attribution.publisher_id !== void 0,
78
+ { error: "token or publisher_id is required", path: ["token"] }
79
+ );
80
+ const ACP_INTERVENTION_TYPES = ["3ds", "biometric", "address_verification"];
81
+ const ACPAgentCapabilitiesSchema = z.object({
82
+ interventions: z.object({
83
+ supported: z.array(z.string()).optional(),
84
+ display_context: z.string().optional(),
85
+ redirect_context: z.string().optional(),
86
+ max_redirects: z.int().min(0).optional(),
87
+ max_interaction_depth: z.int().min(1).optional()
88
+ }).optional(),
89
+ extensions: z.array(z.string()).optional()
20
90
  });
21
91
  const ACPCreateCheckoutSessionRequestSchema = z.object({
92
+ capabilities: ACPAgentCapabilitiesSchema,
93
+ locale: z.string().optional(),
94
+ timezone: z.string().optional(),
95
+ affiliate_attribution: ACPAffiliateAttributionSchema.optional(),
96
+ discounts: ACPDiscountsRequestSchema.optional(),
97
+ /** Deprecated alias of `discounts.codes`. */
98
+ coupons: z.array(z.string().min(1)).optional(),
22
99
  buyer: ACPBuyerSchema.optional(),
23
- items: z.array(ACPItemSchema).min(1),
24
- fulfillment_address: ACPAddressSchema.optional()
100
+ line_items: z.array(ACPItemSchema).min(1),
101
+ currency: z.string().refine(
102
+ (currency) => CurrencySchema.safeParse(currency.toUpperCase()).success,
103
+ "Expected an ISO 4217 currency code"
104
+ ),
105
+ fulfillment_details: ACPFulfillmentDetailsSchema.optional()
25
106
  });
26
107
  const ACPUpdateCheckoutSessionRequestSchema = z.object({
27
108
  buyer: ACPBuyerSchema.optional(),
28
- items: z.array(ACPItemSchema).min(1).optional(),
29
- fulfillment_address: ACPAddressSchema.optional(),
30
- fulfillment_option_id: z.string().optional()
109
+ line_items: z.array(ACPItemSchema).min(1).optional(),
110
+ discounts: ACPDiscountsRequestSchema.optional(),
111
+ /** Deprecated alias of `discounts.codes`. */
112
+ coupons: z.array(z.string().min(1)).optional(),
113
+ // null clears the field; absent leaves it unchanged.
114
+ fulfillment_details: ACPFulfillmentDetailsSchema.nullable().optional(),
115
+ selected_fulfillment_options: z.array(ACPSelectedFulfillmentOptionSchema).nullable().optional()
31
116
  });
32
117
  const ACPPaymentDataSchema = z.object({
33
- token: z.string().min(1),
34
- provider: z.enum(["stripe", "adyen", "braintree"]),
118
+ handler_id: z.string().min(1),
119
+ instrument: z.object({
120
+ type: z.string().min(1),
121
+ credential: z.looseObject({
122
+ type: z.string().min(1),
123
+ token: z.string().min(1)
124
+ })
125
+ }),
35
126
  billing_address: ACPAddressSchema.optional()
36
127
  });
128
+ const ACPAuthenticationMetadataSchema = z.looseObject({
129
+ channel: z.looseObject({}).optional(),
130
+ acquirer_details: z.object({
131
+ acquirer_bin: z.string().max(11),
132
+ acquirer_country: z.string().length(2),
133
+ acquirer_merchant_id: z.string().max(35),
134
+ merchant_name: z.string().max(40),
135
+ requestor_id: z.string().max(35).optional()
136
+ }),
137
+ directory_server: z.enum(["american_express", "mastercard", "visa"]),
138
+ flow_preference: z.object({
139
+ type: z.enum(["challenge", "frictionless"]),
140
+ challenge: z.object({ type: z.enum(["mandated", "preferred"]).optional() }).optional(),
141
+ frictionless: z.object({ type: z.enum(["low_risk"]).optional() }).optional()
142
+ }).optional()
143
+ });
144
+ const ACP_AUTHENTICATED_OUTCOMES = ["authenticated", "attempt_acknowledged", "informational"];
145
+ const ACPAuthenticationResultSchema = z.object({
146
+ outcome: z.enum([
147
+ "abandoned",
148
+ "attempt_acknowledged",
149
+ "authenticated",
150
+ "canceled",
151
+ "denied",
152
+ "informational",
153
+ "internal_error",
154
+ "not_supported",
155
+ "processing_error",
156
+ "rejected"
157
+ ]),
158
+ outcome_details: z.object({
159
+ three_ds_cryptogram: z.string(),
160
+ electronic_commerce_indicator: z.enum(["01", "02", "05", "06", "07"]),
161
+ transaction_id: z.string(),
162
+ version: z.string()
163
+ }).optional()
164
+ }).refine(
165
+ (result) => result.outcome_details !== void 0 || !ACP_AUTHENTICATED_OUTCOMES.includes(result.outcome),
166
+ { error: "outcome_details is required for this outcome", path: ["outcome_details"] }
167
+ );
37
168
  const ACPCompleteCheckoutSessionRequestSchema = z.object({
38
169
  buyer: ACPBuyerSchema.optional(),
39
- payment_data: ACPPaymentDataSchema
170
+ payment_data: ACPPaymentDataSchema,
171
+ authentication_result: ACPAuthenticationResultSchema.optional(),
172
+ affiliate_attribution: ACPAffiliateAttributionSchema.optional(),
173
+ /** The buyer's marketing consent decisions for the offered channels. */
174
+ marketing_consents: z.array(z.object({ channel: z.string().min(1), opted_in: z.boolean() })).optional()
175
+ });
176
+ const ACPCartCreateRequestSchema = z.object({
177
+ line_items: z.array(ACPItemSchema).min(1),
178
+ buyer: ACPBuyerSchema.optional(),
179
+ locale: z.string().optional()
180
+ });
181
+ const ACPCartUpdateRequestSchema = z.object({
182
+ line_items: z.array(ACPItemSchema),
183
+ buyer: ACPBuyerSchema.optional()
184
+ });
185
+ const ACPCartStateSchema = z.looseObject({
186
+ id: z.string(),
187
+ cartId: z.string(),
188
+ sessionId: z.string().optional(),
189
+ agentId: z.string().optional(),
190
+ buyer: ACPBuyerSchema.optional(),
191
+ expiresAt: z.string()
192
+ });
193
+ const ACP_INTENT_TRACE_REASON_CODES = [
194
+ "price_sensitivity",
195
+ "shipping_cost",
196
+ "shipping_speed",
197
+ "product_fit",
198
+ "trust_security",
199
+ "returns_policy",
200
+ "payment_options",
201
+ "comparison",
202
+ "timing_deferred",
203
+ "other"
204
+ ];
205
+ const ACPIntentTraceSchema = z.object({
206
+ reason_code: z.string().min(1).transform((code) => ACP_INTENT_TRACE_REASON_CODES.includes(code) ? code : "other"),
207
+ trace_summary: z.string().max(500).optional(),
208
+ metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
209
+ });
210
+ const ACPCancelCheckoutSessionRequestSchema = z.object({
211
+ intent_trace: ACPIntentTraceSchema.optional()
40
212
  });
41
213
  const ACPCheckoutSessionStateSchema = z.looseObject({
42
214
  id: z.string(),
43
215
  // The ACP session owning the backend cart, resumed for later requests.
44
216
  sessionId: z.string().optional(),
217
+ /** The authenticated agent that created the session and may access it. */
218
+ agentId: z.string().optional(),
45
219
  cartId: z.string(),
220
+ /** BCP 47 locale and IANA timezone requested on creation. */
221
+ locale: z.string().optional(),
222
+ timezone: z.string().optional(),
223
+ createdAt: z.string().optional(),
224
+ updatedAt: z.string().optional(),
225
+ /** What the agent declared on creation; capabilities are write-only. */
226
+ agentCapabilities: ACPAgentCapabilitiesSchema.optional(),
227
+ /** ISO 4217 currency requested on creation, lower case. */
228
+ currency: z.string().optional(),
46
229
  // Set once completion has created the real reactionary checkout.
47
230
  checkoutId: z.string().optional(),
48
231
  status: z.enum([
49
232
  "not_ready_for_payment",
50
233
  "ready_for_payment",
234
+ "authentication_required",
51
235
  "in_progress",
236
+ "complete_in_progress",
52
237
  "completed",
53
238
  "canceled"
54
239
  ]),
55
240
  buyer: ACPBuyerSchema.optional(),
56
- fulfillmentAddress: ACPAddressSchema.optional(),
241
+ /** Affiliate attribution by touchpoint; write-only, never returned. */
242
+ firstTouchAttribution: z.looseObject({ provider: z.string() }).optional(),
243
+ lastTouchAttribution: z.looseObject({ provider: z.string() }).optional(),
244
+ /** Marketing consents submitted on completion, reported with the order. */
245
+ marketingConsents: z.array(z.looseObject({ channel: z.string(), opted_in: z.boolean() })).optional(),
246
+ /** Why the agent canceled the session; write-only, never returned. */
247
+ intentTrace: z.looseObject({
248
+ reason_code: z.string(),
249
+ trace_summary: z.string().optional(),
250
+ metadata: z.record(z.string(), z.union([z.string(), z.number(), z.boolean()])).optional()
251
+ }).optional(),
252
+ /** Discount codes submitted (discount extension) and those rejected. */
253
+ discountCodes: z.array(z.string()).optional(),
254
+ rejectedDiscounts: z.array(ACPRejectedDiscountSchema).optional(),
255
+ /** The 3DS metadata the session awaits an authentication result for. */
256
+ authenticationMetadata: ACPAuthenticationMetadataSchema.optional(),
257
+ fulfillmentDetails: ACPFulfillmentDetailsSchema.optional(),
258
+ // The backend checkout has one shipping method, so one option is kept.
57
259
  fulfillmentOptionId: z.string().optional(),
58
260
  orderId: z.string().optional()
59
261
  });
60
- const ACPDescriptionSchema = z.object({
61
- plain: z.string().optional(),
62
- html: z.string().optional(),
63
- markdown: z.string().optional()
64
- });
65
- const ACPMediaSchema = z.object({
66
- url: z.string(),
67
- alt_text: z.string().optional()
68
- });
69
- const ACPFeedPriceSchema = z.object({
70
- amount: z.int(),
71
- currency: z.string().length(3)
72
- });
73
- const ACPAvailabilitySchema = z.object({
74
- available: z.boolean().optional(),
75
- status: z.string().optional()
76
- });
77
- const ACPBarcodeSchema = z.object({
78
- type: z.string(),
79
- value: z.string()
80
- });
81
- const ACPVariantOptionSchema = z.object({
82
- name: z.string(),
83
- value: z.string()
84
- });
85
- const ACPFeedVariantSchema = z.object({
262
+ const ACPOrderRecordSchema = z.looseObject({
86
263
  id: z.string(),
87
- title: z.string(),
88
- description: ACPDescriptionSchema.optional(),
89
- url: z.string().optional(),
90
- barcodes: z.array(ACPBarcodeSchema).optional(),
91
- price: ACPFeedPriceSchema.optional(),
92
- list_price: ACPFeedPriceSchema.optional(),
93
- availability: ACPAvailabilitySchema.optional(),
94
- variant_options: z.array(ACPVariantOptionSchema).optional(),
95
- media: z.array(ACPMediaSchema).optional()
96
- });
97
- const ACPFeedProductSchema = z.object({
98
- id: z.string(),
99
- title: z.string().optional(),
100
- description: ACPDescriptionSchema.optional(),
101
- url: z.string().optional(),
102
- media: z.array(ACPMediaSchema).optional(),
103
- variants: z.array(ACPFeedVariantSchema).min(1)
104
- });
105
- const ACPProductFeedResponseSchema = z.object({
106
- target_country: z.string().length(2).optional(),
107
- products: z.array(ACPFeedProductSchema)
264
+ checkoutSessionId: z.string(),
265
+ /** The ACP session that placed the order; backends scope orders to it. */
266
+ sessionId: z.string().optional(),
267
+ /** The agent that placed the order, whose webhook receives its events. */
268
+ agentId: z.string().optional()
108
269
  });
109
270
  export {
110
271
  ACPAddressSchema,
111
- ACPAvailabilitySchema,
112
- ACPBarcodeSchema,
272
+ ACPAffiliateAttributionSchema,
273
+ ACPAgentCapabilitiesSchema,
274
+ ACPAuthenticationMetadataSchema,
275
+ ACPAuthenticationResultSchema,
113
276
  ACPBuyerSchema,
277
+ ACPCancelCheckoutSessionRequestSchema,
278
+ ACPCartCreateRequestSchema,
279
+ ACPCartStateSchema,
280
+ ACPCartUpdateRequestSchema,
114
281
  ACPCheckoutSessionStateSchema,
282
+ ACPCompanyInfoSchema,
115
283
  ACPCompleteCheckoutSessionRequestSchema,
116
284
  ACPCreateCheckoutSessionRequestSchema,
117
- ACPDescriptionSchema,
118
- ACPFeedPriceSchema,
119
- ACPFeedProductSchema,
120
- ACPFeedVariantSchema,
285
+ ACPFulfillmentDetailsSchema,
286
+ ACPIntentTraceSchema,
121
287
  ACPItemSchema,
122
- ACPMediaSchema,
288
+ ACPLoyaltyInfoSchema,
289
+ ACPOrderRecordSchema,
123
290
  ACPPaymentDataSchema,
124
- ACPProductFeedResponseSchema,
291
+ ACPSelectedFulfillmentOptionSchema,
292
+ ACPTaxExemptionSchema,
125
293
  ACPUpdateCheckoutSessionRequestSchema,
126
- ACPVariantOptionSchema
294
+ ACP_AUTHENTICATED_OUTCOMES,
295
+ ACP_INTENT_TRACE_REASON_CODES,
296
+ ACP_INTERVENTION_TYPES
127
297
  };
@@ -0,0 +1,72 @@
1
+ import { createHmac } from "node:crypto";
2
+ const DEFAULT_RETRY_DELAYS_MS = [500, 2e3, 1e4, 6e4, 3e5];
3
+ const REQUEST_TIMEOUT_MS = 5e3;
4
+ class ACPOrderWebhooks {
5
+ constructor(options) {
6
+ this.options = options;
7
+ this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
8
+ this.fetchImpl = options.fetch ?? fetch;
9
+ for (const endpoint of options.endpoints) {
10
+ if (!endpoint.secret || !this.isAllowedUrl(endpoint.url)) {
11
+ throw new Error(`ACP webhook endpoint ${endpoint.url} needs an https URL and a secret.`);
12
+ }
13
+ }
14
+ }
15
+ options;
16
+ retryDelaysMs;
17
+ fetchImpl;
18
+ /** The endpoint for an agent's orders, if any. */
19
+ getEndpoint(agentId) {
20
+ return this.options.endpoints.find((endpoint) => agentId !== void 0 && endpoint.agentId === agentId) ?? this.options.endpoints.find((endpoint) => endpoint.agentId === void 0);
21
+ }
22
+ /** Sends an event without waiting for it; the order is the full current state. */
23
+ deliver(endpoint, type, order) {
24
+ const body = JSON.stringify({ type, data: order });
25
+ const requestId = `evt_${crypto.randomUUID()}`;
26
+ void this.attempt(endpoint, body, requestId, 0);
27
+ }
28
+ async attempt(endpoint, body, requestId, attempt) {
29
+ let failure;
30
+ try {
31
+ const response = await this.fetchImpl(endpoint.url, {
32
+ method: "POST",
33
+ headers: {
34
+ "content-type": "application/json",
35
+ "request-id": requestId,
36
+ "merchant-signature": signWebhookPayload(body, endpoint.secret)
37
+ },
38
+ body,
39
+ redirect: "manual",
40
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
41
+ });
42
+ if (response.ok) {
43
+ return;
44
+ }
45
+ failure = `HTTP ${response.status}`;
46
+ } catch (error) {
47
+ failure = error instanceof Error ? error.message : String(error);
48
+ }
49
+ const delay = this.retryDelaysMs[attempt];
50
+ if (delay === void 0) {
51
+ console.error(`ACP: giving up on webhook ${requestId} to ${endpoint.url} after ${attempt + 1} attempts: ${failure}`);
52
+ return;
53
+ }
54
+ setTimeout(() => void this.attempt(endpoint, body, requestId, attempt + 1), delay).unref();
55
+ }
56
+ isAllowedUrl(value) {
57
+ try {
58
+ const { protocol } = new URL(value);
59
+ return protocol === "https:" || protocol === "http:" && Boolean(this.options.allowInsecureUrls);
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+ }
65
+ function signWebhookPayload(body, secret, timestamp = Math.floor(Date.now() / 1e3)) {
66
+ const signature = createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
67
+ return `t=${timestamp},v1=${signature}`;
68
+ }
69
+ export {
70
+ ACPOrderWebhooks,
71
+ signWebhookPayload
72
+ };