PluginProbe ʕ •ᴥ•ʔ
Microsoft Clarity / 0.10.28
Microsoft Clarity v0.10.28
0.10.29 0.10.28 0.10.27 0.10.26 0.10.25 0.10.24 0.8.0 0.9.0 0.9.1 0.9.2 0.9.3 0.9.4 trunk 0.10.0 0.10.1 0.10.10 0.10.11 0.10.12 0.10.13 0.10.14 0.10.15 0.10.16 0.10.17 0.10.18 0.10.19 0.10.2 0.10.20 0.10.21 0.10.22 0.10.23 0.10.3 0.10.4 0.10.5 0.10.6 0.10.7 0.10.8 0.10.9 0.2 0.4 0.5 0.6 0.6.1 0.7 0.7.1 0.7.2 0.7.3 0.7.4 0.7.5
microsoft-clarity / includes / brandagent-custom-webhooks.php
microsoft-clarity / includes Last commit date
webhooks 5 months ago brandagent-config.php 1 week ago brandagent-content-webhooks.php 1 week ago brandagent-custom-webhooks.php 3 months ago brandagent-endpoint.php 1 week ago brandagent-rest-api.php 3 months ago brandagent-webhooks.php 3 months ago brandagent-wordpress.php 1 week ago
brandagent-custom-webhooks.php
896 lines
1 <?php
2 /**
3 * BrandAgent Custom Webhooks
4 *
5 * Registers custom webhook topics for WooCommerce that aren't built-in
6 * (e.g., cart updates)
7 *
8 * @package MicrosoftClarity
9 * @since 0.10.21
10 */
11
12 // Exit if accessed directly
13 defined( 'ABSPATH' ) || exit;
14
15 /**
16 * Register 'cart' and 'checkout' as valid webhook resources
17 * This is required for WooCommerce to accept 'cart.updated' and 'checkout.started' as valid topics
18 *
19 * @param array $resources Valid webhook resources (order, product, customer, coupon)
20 * @return array Modified resources including 'cart' and 'checkout'
21 */
22 function brandagent_register_custom_webhook_resources( $resources ) {
23 $resources[] = 'cart';
24 $resources[] = 'checkout';
25 return $resources;
26 }
27 add_filter( 'woocommerce_valid_webhook_resources', 'brandagent_register_custom_webhook_resources' );
28
29 /**
30 * Register custom events for custom resources
31 * WooCommerce validates topics as {resource}.{event}
32 *
33 * @param array $events Valid webhook events (created, updated, deleted, restored)
34 * @return array Modified events
35 */
36 function brandagent_register_custom_webhook_events( $events ) {
37 // 'updated' is already in default events, but let's ensure it
38 if ( ! in_array( 'updated', $events, true ) ) {
39 $events[] = 'updated';
40 }
41 // 'created' is already in default events, but ensure it for checkout
42 if ( ! in_array( 'created', $events, true ) ) {
43 $events[] = 'created';
44 }
45 return $events;
46 }
47 add_filter( 'woocommerce_valid_webhook_events', 'brandagent_register_custom_webhook_events' );
48
49 /**
50 * Register custom webhook topics for admin dropdown display
51 *
52 * @param array $topics Existing webhook topics
53 * @return array Modified webhook topics
54 */
55 function brandagent_register_custom_webhook_topics( $topics ) {
56 $topics['cart.updated'] = __( 'Cart Updated', 'microsoft-clarity' );
57 $topics['checkout.created'] = __( 'Checkout Created', 'microsoft-clarity' );
58 return $topics;
59 }
60 add_filter( 'woocommerce_webhook_topics', 'brandagent_register_custom_webhook_topics' );
61
62 /**
63 * Register valid webhook events for custom topics
64 *
65 * @param array $events Valid events for the topic
66 * @param string $topic The webhook topic
67 * @return array Modified events
68 */
69 function brandagent_register_custom_webhook_topic_events( $events, $topic ) {
70 if ( 'cart.updated' === $topic ) {
71 $events = array(
72 'woocommerce_add_to_cart',
73 'woocommerce_cart_item_removed',
74 'woocommerce_cart_item_restored',
75 'woocommerce_after_cart_item_quantity_update',
76 );
77 }
78
79 if ( 'checkout.created' === $topic ) {
80 // This is manually triggered, but we need a placeholder hook
81 $events = array(
82 'brandagent_checkout_created',
83 );
84 }
85
86 return $events;
87 }
88 add_filter( 'woocommerce_webhook_topic_hooks', 'brandagent_register_custom_webhook_topic_events', 10, 2 );
89
90 /**
91 * Build the payload for cart webhooks
92 *
93 * @param array $payload The webhook payload
94 * @param string $resource The resource type
95 * @param int $resource_id The resource ID
96 * @param int $webhook_id The webhook ID
97 * @return array The modified payload
98 */
99 function brandagent_cart_webhook_payload( $payload, $resource, $resource_id, $webhook_id ) {
100 $webhook = wc_get_webhook( $webhook_id );
101
102 if ( ! $webhook || $webhook->get_topic() !== 'cart.updated' ) {
103 return $payload;
104 }
105
106 // Get the change context set by the trigger
107 $change_context = BrandAgent_Cart_Webhook_Trigger::get_change_context();
108
109 // Get current cart data
110 $cart = WC()->cart;
111
112 // Get cart ID (session-based identifier)
113 $cart_id = WC()->session ? WC()->session->get_customer_id() : null;
114
115 // Get store currency
116 $currency = get_woocommerce_currency();
117
118 // Retrieve BrandAgent session attributes set by the frontend via the REST API.
119 // This is the WooCommerce analog of Shopify's _agentClientInfo note attribute.
120 $brandagent_attrs = BrandAgent_REST_API::get_cart_attributes();
121
122 // Fall back to the BrandAgent clientId when WC session is unavailable (cart_id would be null).
123 if ( ! $cart_id && ! empty( $brandagent_attrs['clientId'] ) ) {
124 $cart_id = $brandagent_attrs['clientId'];
125 }
126
127 if ( ! $cart ) {
128 return array(
129 'event' => 'cart.updated',
130 'action' => $change_context['action'] ?? 'unknown',
131 'cart_id' => $cart_id,
132 'currency' => $currency,
133 'changed_item' => null,
134 'cart' => null,
135 'customer' => array(
136 'id' => get_current_user_id(),
137 'session_id' => ! empty( $brandagent_attrs['sessionId'] )
138 ? $brandagent_attrs['sessionId']
139 : $cart_id,
140 ),
141 'brandagent_attributes' => $brandagent_attrs,
142 'timestamp' => current_time( 'c' ),
143 );
144 }
145
146 $cart_items = array();
147
148 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
149 $product = $cart_item['data'];
150 $parent_product = null;
151 $variant_title = '';
152
153 // Get parent product and variant title if this is a variation
154 if ( $cart_item['variation_id'] ) {
155 $parent_product = wc_get_product( $cart_item['product_id'] );
156 $variant_title = $product ? $product->get_name() : '';
157 }
158
159 $item_data = array(
160 'key' => $cart_item_key,
161 'product_id' => $cart_item['product_id'],
162 'product_title' => $parent_product ? $parent_product->get_name() : ( $product ? $product->get_name() : '' ),
163 'variation_id' => $cart_item['variation_id'],
164 'variant_title' => $variant_title,
165 'quantity' => $cart_item['quantity'],
166 'line_total' => $cart_item['line_total'],
167 'line_subtotal' => $cart_item['line_subtotal'],
168 'product_sku' => $product ? $product->get_sku() : '',
169 'product_price' => $product ? $product->get_price() : 0,
170 );
171 $cart_items[] = $item_data;
172 }
173
174 // Build the changed item info
175 $changed_item = null;
176 if ( $change_context ) {
177 $changed_item = array(
178 'key' => $change_context['cart_item_key'] ?? null,
179 'product_id' => $change_context['product_id'] ?? null,
180 'product_title' => $change_context['product_title'] ?? null,
181 'variation_id' => $change_context['variation_id'] ?? null,
182 'variant_title' => $change_context['variant_title'] ?? null,
183 'quantity' => $change_context['quantity'] ?? null,
184 'old_quantity' => $change_context['old_quantity'] ?? null,
185 'product_sku' => $change_context['product_sku'] ?? null,
186 'product_price' => $change_context['product_price'] ?? null,
187 );
188 }
189
190 return array(
191 'event' => 'cart.updated',
192 'action' => $change_context['action'] ?? 'unknown',
193 'cart_id' => $cart_id,
194 'currency' => $currency,
195 'changed_item' => $changed_item,
196 'cart' => array(
197 'items' => $cart_items,
198 'item_count' => $cart->get_cart_contents_count(),
199 'total' => $cart->get_cart_contents_total(),
200 'subtotal' => $cart->get_subtotal(),
201 'tax_total' => $cart->get_cart_contents_tax(),
202 ),
203 'customer' => array(
204 'id' => get_current_user_id(),
205 'session_id' => ! empty( $brandagent_attrs['sessionId'] )
206 ? $brandagent_attrs['sessionId']
207 : $cart_id,
208 ),
209 'brandagent_attributes' => $brandagent_attrs,
210 'timestamp' => current_time( 'c' ),
211 );
212 }
213 add_filter( 'woocommerce_webhook_payload', 'brandagent_cart_webhook_payload', 10, 4 );
214
215 /**
216 * Get active webhooks for a specific topic
217 *
218 * Uses static memoization to avoid repeated database queries within the same
219 * request (e.g., multiple cart actions in a single page load).
220 *
221 * @param string $topic The webhook topic (e.g., 'cart.updated', 'checkout.created')
222 * @return array Array of WC_Webhook objects
223 */
224 function brandagent_get_webhooks_by_topic( $topic ) {
225 static $cache = array();
226
227 // Return cached result if available for this request
228 if ( isset( $cache[ $topic ] ) ) {
229 return $cache[ $topic ];
230 }
231
232 $data_store = WC_Data_Store::load( 'webhook' );
233
234 // Search for active webhooks
235 $webhook_ids = $data_store->search_webhooks(
236 array(
237 'status' => 'active',
238 'limit' => -1,
239 )
240 );
241
242 $webhooks = array();
243 foreach ( $webhook_ids as $webhook_id ) {
244 $webhook = wc_get_webhook( $webhook_id );
245 if ( $webhook && $webhook->get_topic() === $topic ) {
246 $webhooks[] = $webhook;
247 }
248 }
249
250 // Cache for subsequent calls within this request
251 $cache[ $topic ] = $webhooks;
252
253 return $webhooks;
254 }
255
256 /**
257 * Deliver a custom webhook payload
258 *
259 * @param WC_Webhook $webhook The webhook to deliver
260 * @param string $resource The resource type (e.g., 'cart', 'checkout')
261 * @param string $topic The webhook topic (e.g., 'cart.updated', 'checkout.created')
262 * @param string $event The event type (e.g., 'updated', 'created')
263 */
264 function brandagent_deliver_custom_webhook( $webhook, $resource, $topic, $event ) {
265 // Build the payload
266 $payload = apply_filters( 'woocommerce_webhook_payload', array(), $resource, 0, $webhook->get_id() );
267
268 if ( empty( $payload ) ) {
269 brandagent_log( 'BrandAgent Custom Webhooks: Skipping delivery because payload is empty', array(
270 'webhook_id' => $webhook->get_id(),
271 'resource' => $resource,
272 'topic' => $topic,
273 'event' => $event,
274 ) );
275 return;
276 }
277
278 // Get delivery URL and secret
279 $delivery_url = $webhook->get_delivery_url();
280 $secret = $webhook->get_secret();
281
282 // Build signature
283 $body = wp_json_encode( $payload );
284 $signature = base64_encode( hash_hmac( 'sha256', $body, $secret, true ) );
285
286 // Get store URL for X-WC-Webhook-Source header (standard WooCommerce header)
287 $store_url = home_url();
288
289 // Generate HMAC authentication headers for Brand Agent server
290 $hmac_secret = brandagent_get_hmac_secret();
291 $hmac_client_id = brandagent_normalize_store_url( $store_url );
292 $hmac_timestamp = time();
293 $hmac_signature = $hmac_secret
294 ? brandagent_generate_hmac_signature( $hmac_client_id, $hmac_timestamp, $hmac_secret )
295 : '';
296 if ( ! $hmac_secret ) {
297 brandagent_log( 'BrandAgent Custom Webhooks: Missing HMAC secret for custom webhook delivery', array(
298 'webhook_id' => $webhook->get_id(),
299 'resource' => $resource,
300 'topic' => $topic,
301 ) );
302 }
303
304 // Build headers
305 $headers = array(
306 'Content-Type' => 'application/json',
307 'X-WC-Webhook-Source' => $store_url,
308 'X-WC-Webhook-Topic' => $topic,
309 'X-WC-Webhook-Resource' => $resource,
310 'X-WC-Webhook-Event' => $event,
311 'X-WC-Webhook-Signature' => $signature,
312 'X-WC-Webhook-ID' => $webhook->get_id(),
313 'X-WC-Webhook-Delivery-ID' => wp_generate_uuid4(),
314 'User-Agent' => 'WooCommerce/' . WC_VERSION . ' Hookshot (WordPress/' . get_bloginfo( 'version' ) . ')',
315 );
316
317 // Add HMAC authentication headers if secret is available
318 if ( $hmac_secret ) {
319 $headers['X-WooCommerce-Client-Id'] = $hmac_client_id;
320 $headers['X-WooCommerce-Store-Url'] = $store_url;
321 $headers['X-WooCommerce-Signature'] = $hmac_signature;
322 $headers['X-WooCommerce-Timestamp'] = (string) $hmac_timestamp;
323 }
324
325 // Deliver the webhook (non-blocking to avoid delaying page load)
326 $response = wp_remote_post(
327 $delivery_url,
328 array(
329 'method' => 'POST',
330 'timeout' => 0.01,
331 'blocking' => false,
332 'headers' => $headers,
333 'body' => $body,
334 )
335 );
336
337 // Note: With blocking=false, we can only detect immediate failures (e.g., invalid URL).
338 // Network/server errors won't be caught since we don't wait for the response.
339 if ( is_wp_error( $response ) ) {
340 brandagent_log( 'BrandAgent Custom Webhooks: Webhook delivery failed', array(
341 'webhook_id' => $webhook->get_id(),
342 'resource' => $resource,
343 'topic' => $topic,
344 'error' => $response->get_error_message(),
345 ) );
346 }
347 }
348
349 /**
350 * Manually trigger cart webhooks on cart actions
351 * This is needed because cart actions don't follow the standard WooCommerce webhook pattern
352 */
353 class BrandAgent_Cart_Webhook_Trigger {
354
355 /**
356 * Store the context of what changed in the cart
357 * @var array|null
358 */
359 private static $change_context = null;
360
361 /**
362 * Get the current change context
363 * @return array|null
364 */
365 public static function get_change_context() {
366 return self::$change_context;
367 }
368
369 /**
370 * Initialize cart webhook triggers
371 */
372 public static function init() {
373 // Only register hooks if BrandAgent is enabled to avoid unnecessary processing
374 if ( get_option( 'BAInjectFrontendScript' ) !== 'true' ) {
375 return;
376 }
377
378 // Hook into cart actions with specific handlers
379 add_action( 'woocommerce_add_to_cart', array( __CLASS__, 'on_add_to_cart' ), 99, 6 );
380 add_action( 'woocommerce_cart_item_removed', array( __CLASS__, 'on_item_removed' ), 99, 2 );
381 add_action( 'woocommerce_cart_item_restored', array( __CLASS__, 'on_item_restored' ), 99, 2 );
382 add_action( 'woocommerce_after_cart_item_quantity_update', array( __CLASS__, 'on_quantity_update' ), 99, 4 );
383 }
384
385 /**
386 * Handle add to cart action
387 *
388 * @param string $cart_item_key Cart item key
389 * @param int $product_id Product ID
390 * @param int $quantity Quantity added
391 * @param int $variation_id Variation ID (0 if not a variation)
392 * @param array $variation Variation data
393 * @param array $cart_item_data Additional cart item data
394 */
395 public static function on_add_to_cart( $cart_item_key, $product_id, $quantity, $variation_id, $variation, $cart_item_data ) {
396 $product = wc_get_product( $variation_id ? $variation_id : $product_id );
397 $parent_product = $variation_id ? wc_get_product( $product_id ) : null;
398
399 // Get product title (parent product name for variations)
400 $product_title = $parent_product ? $parent_product->get_name() : ( $product ? $product->get_name() : '' );
401 // Get variant title (only for variations)
402 $variant_title = $variation_id && $product ? $product->get_name() : '';
403
404 self::$change_context = array(
405 'action' => 'added',
406 'cart_item_key' => $cart_item_key,
407 'product_id' => $product_id,
408 'product_title' => $product_title,
409 'variation_id' => $variation_id,
410 'variant_title' => $variant_title,
411 'quantity' => $quantity,
412 'old_quantity' => 0,
413 'product_sku' => $product ? $product->get_sku() : '',
414 'product_price' => $product ? $product->get_price() : 0,
415 );
416
417 self::trigger_webhooks();
418 }
419
420 /**
421 * Handle item removed action
422 * Note: The item is already removed from cart when this fires
423 *
424 * @param string $cart_item_key Cart item key
425 * @param WC_Cart $cart Cart object
426 */
427 public static function on_item_removed( $cart_item_key, $cart ) {
428 // Get the removed item from the removed_cart_contents
429 $removed_item = isset( $cart->removed_cart_contents[ $cart_item_key ] )
430 ? $cart->removed_cart_contents[ $cart_item_key ]
431 : null;
432
433 $product = null;
434 $parent_product = null;
435 $product_id = 0;
436 $variation_id = 0;
437 $quantity = 0;
438 $product_title = '';
439 $variant_title = '';
440
441 if ( $removed_item ) {
442 $product_id = $removed_item['product_id'];
443 $variation_id = $removed_item['variation_id'];
444 $quantity = $removed_item['quantity'];
445 $product = isset( $removed_item['data'] ) ? $removed_item['data'] : wc_get_product( $variation_id ? $variation_id : $product_id );
446
447 // Get parent product for variations
448 if ( $variation_id ) {
449 $parent_product = wc_get_product( $product_id );
450 $product_title = $parent_product ? $parent_product->get_name() : '';
451 $variant_title = $product ? $product->get_name() : '';
452 } else {
453 $product_title = $product ? $product->get_name() : '';
454 }
455 }
456
457 self::$change_context = array(
458 'action' => 'removed',
459 'cart_item_key' => $cart_item_key,
460 'product_id' => $product_id,
461 'product_title' => $product_title,
462 'variation_id' => $variation_id,
463 'variant_title' => $variant_title,
464 'quantity' => 0,
465 'old_quantity' => $quantity,
466 'product_sku' => $product ? $product->get_sku() : '',
467 'product_price' => $product ? $product->get_price() : 0,
468 );
469
470 self::trigger_webhooks();
471 }
472
473 /**
474 * Handle item restored action
475 *
476 * @param string $cart_item_key Cart item key
477 * @param WC_Cart $cart Cart object
478 */
479 public static function on_item_restored( $cart_item_key, $cart ) {
480 $cart_item = $cart->get_cart_item( $cart_item_key );
481 $product = isset( $cart_item['data'] ) ? $cart_item['data'] : null;
482 $product_id = $cart_item['product_id'] ?? 0;
483 $variation_id = $cart_item['variation_id'] ?? 0;
484
485 // Get parent product for variations
486 $parent_product = $variation_id ? wc_get_product( $product_id ) : null;
487 $product_title = $parent_product ? $parent_product->get_name() : ( $product ? $product->get_name() : '' );
488 $variant_title = $variation_id && $product ? $product->get_name() : '';
489
490 self::$change_context = array(
491 'action' => 'restored',
492 'cart_item_key' => $cart_item_key,
493 'product_id' => $product_id,
494 'product_title' => $product_title,
495 'variation_id' => $variation_id,
496 'variant_title' => $variant_title,
497 'quantity' => $cart_item['quantity'] ?? 0,
498 'old_quantity' => 0,
499 'product_sku' => $product ? $product->get_sku() : '',
500 'product_price' => $product ? $product->get_price() : 0,
501 );
502
503 self::trigger_webhooks();
504 }
505
506 /**
507 * Handle quantity update action
508 *
509 * @param string $cart_item_key Cart item key
510 * @param int $quantity New quantity
511 * @param int $old_quantity Previous quantity
512 * @param WC_Cart $cart Cart object
513 */
514 public static function on_quantity_update( $cart_item_key, $quantity, $old_quantity, $cart ) {
515 $cart_item = $cart->get_cart_item( $cart_item_key );
516 $product = isset( $cart_item['data'] ) ? $cart_item['data'] : null;
517 $product_id = $cart_item['product_id'] ?? 0;
518 $variation_id = $cart_item['variation_id'] ?? 0;
519
520 // Get parent product for variations
521 $parent_product = $variation_id ? wc_get_product( $product_id ) : null;
522 $product_title = $parent_product ? $parent_product->get_name() : ( $product ? $product->get_name() : '' );
523 $variant_title = $variation_id && $product ? $product->get_name() : '';
524
525 self::$change_context = array(
526 'action' => 'quantity_updated',
527 'cart_item_key' => $cart_item_key,
528 'product_id' => $product_id,
529 'product_title' => $product_title,
530 'variation_id' => $variation_id,
531 'variant_title' => $variant_title,
532 'quantity' => $quantity,
533 'old_quantity' => $old_quantity,
534 'product_sku' => $product ? $product->get_sku() : '',
535 'product_price' => $product ? $product->get_price() : 0,
536 );
537
538 self::trigger_webhooks();
539 }
540
541 /**
542 * Trigger all cart.updated webhooks
543 */
544 private static function trigger_webhooks() {
545 // Don't trigger if cart not initialized
546 if ( ! WC()->cart ) {
547 brandagent_log( 'BrandAgent Custom Webhooks: Skipping cart webhook trigger because cart is not initialized' );
548 self::$change_context = null;
549 return;
550 }
551
552 // Find all active cart.updated webhooks
553 $webhooks = brandagent_get_webhooks_by_topic( 'cart.updated' );
554
555 foreach ( $webhooks as $webhook ) {
556 if ( $webhook->get_status() === 'active' ) {
557 brandagent_deliver_custom_webhook( $webhook, 'cart', 'cart.updated', 'updated' );
558 }
559 }
560
561 // Clear context after delivery
562 self::$change_context = null;
563 }
564 }
565
566 // Initialize cart webhook triggers when WooCommerce is loaded
567 add_action( 'woocommerce_loaded', array( 'BrandAgent_Cart_Webhook_Trigger', 'init' ) );
568
569 /**
570 * Enhance order webhook payload for BrandAgent webhooks
571 * Adds product_title, variant_title, ensures currency is included,
572 * and includes BrandAgent session attributes for conversion attribution.
573 *
574 * @param array $payload The webhook payload
575 * @param string $resource The resource type
576 * @param int $resource_id The resource ID (order ID)
577 * @param int $webhook_id The webhook ID
578 * @return array The modified payload
579 */
580 function brandagent_order_webhook_payload( $payload, $resource, $resource_id, $webhook_id ) {
581 // Only process order webhooks
582 if ( $resource !== 'order' ) {
583 return $payload;
584 }
585
586 $webhook = wc_get_webhook( $webhook_id );
587
588 // Only enhance BrandAgent webhooks
589 if ( ! $webhook || strpos( $webhook->get_name(), 'BrandAgent' ) !== 0 ) {
590 return $payload;
591 }
592
593 // Get the order
594 $order = wc_get_order( $resource_id );
595 if ( ! $order ) {
596 return $payload;
597 }
598
599 // Get cart ID if available (stored as meta during checkout)
600 $cart_id = $order->get_cart_hash();
601 if ( ! $cart_id ) {
602 $cart_id = $order->get_customer_id() ? 'user_' . $order->get_customer_id() : null;
603 }
604
605 // Retrieve BrandAgent session attributes stored as order meta during checkout.
606 // These are saved by brandagent_save_attrs_to_order() when the order is created.
607 $brandagent_attrs_json = $order->get_meta( '_brandagent_client_info' );
608 $brandagent_attrs = $brandagent_attrs_json ? json_decode( $brandagent_attrs_json, true ) : null;
609
610 // Fall back to the BrandAgent clientId when cart_id is unavailable (e.g., guest orders).
611 if ( ! $cart_id && ! empty( $brandagent_attrs['clientId'] ) ) {
612 $cart_id = $brandagent_attrs['clientId'];
613 }
614
615 $payload['cart_id'] = $cart_id;
616 $payload['brandagent_attributes'] = $brandagent_attrs;
617
618 // Set session_id from agent attributes (consistent with cart/checkout webhooks).
619 if ( ! empty( $brandagent_attrs['sessionId'] ) ) {
620 $payload['session_id'] = $brandagent_attrs['sessionId'];
621 }
622
623 // Enhance line items with product_title and variant_title
624 if ( isset( $payload['line_items'] ) && is_array( $payload['line_items'] ) ) {
625 foreach ( $payload['line_items'] as $key => $item ) {
626 $product_id = $item['product_id'] ?? 0;
627 $variation_id = $item['variation_id'] ?? 0;
628
629 // Get the product
630 $product = wc_get_product( $variation_id ? $variation_id : $product_id );
631 $parent_product = $variation_id ? wc_get_product( $product_id ) : null;
632
633 // Add product_title (parent product name for variations)
634 $payload['line_items'][ $key ]['product_title'] = $parent_product
635 ? $parent_product->get_name()
636 : ( $product ? $product->get_name() : ( $item['name'] ?? '' ) );
637
638 // Add variant_title (only for variations)
639 $payload['line_items'][ $key ]['variant_title'] = $variation_id && $product
640 ? $product->get_name()
641 : '';
642 }
643 }
644
645 return $payload;
646 }
647 add_filter( 'woocommerce_webhook_payload', 'brandagent_order_webhook_payload', 10, 4 );
648
649 /**
650 * Save BrandAgent session attributes to order meta when the order is created.
651 *
652 * By the time the order.updated webhook fires asynchronously, the WooCommerce
653 * session may be destroyed. Persisting to order meta at checkout time ensures
654 * the attributes survive for the order webhook payload builder to read back.
655 *
656 * This is the WooCommerce equivalent of how Shopify's _agentClientInfo note
657 * attribute travels from cart → checkout → order.
658 *
659 * @param WC_Order $order The order being created.
660 */
661 function brandagent_save_attrs_to_order( $order ) {
662 $attrs = BrandAgent_REST_API::get_cart_attributes();
663 if ( $attrs ) {
664 $order->update_meta_data( '_brandagent_client_info', wp_json_encode( $attrs ) );
665 $order->save();
666 }
667 }
668 add_action( 'woocommerce_checkout_order_created', 'brandagent_save_attrs_to_order' ); // classic checkout
669 add_action( 'woocommerce_store_api_checkout_update_order_meta', 'brandagent_save_attrs_to_order' ); // blocks checkout (another way to checkout)
670
671 /**
672 * Build the payload for checkout webhooks
673 *
674 * @param array $payload The webhook payload
675 * @param string $resource The resource type
676 * @param int $resource_id The resource ID
677 * @param int $webhook_id The webhook ID
678 * @return array The modified payload
679 */
680 function brandagent_checkout_webhook_payload( $payload, $resource, $resource_id, $webhook_id ) {
681 $webhook = wc_get_webhook( $webhook_id );
682
683 if ( ! $webhook || $webhook->get_topic() !== 'checkout.created' ) {
684 return $payload;
685 }
686
687 // Get current cart data
688 $cart = WC()->cart;
689
690 // Get cart ID (session-based identifier)
691 $cart_id = WC()->session ? WC()->session->get_customer_id() : null;
692
693 // Get store currency
694 $currency = get_woocommerce_currency();
695
696 // Retrieve BrandAgent session attributes set by the frontend via the REST API.
697 $brandagent_attrs = BrandAgent_REST_API::get_cart_attributes();
698
699 // Fall back to the BrandAgent clientId when WC session is unavailable (cart_id would be null).
700 if ( ! $cart_id && ! empty( $brandagent_attrs['clientId'] ) ) {
701 $cart_id = $brandagent_attrs['clientId'];
702 }
703
704 if ( ! $cart ) {
705 return array(
706 'event' => 'checkout.created',
707 'cart_id' => $cart_id,
708 'currency' => $currency,
709 'checkout_url' => wc_get_checkout_url(),
710 'line_items' => array(),
711 'cart' => null,
712 'customer' => null,
713 'session_id' => ! empty( $brandagent_attrs['sessionId'] )
714 ? $brandagent_attrs['sessionId']
715 : $cart_id,
716 'brandagent_attributes' => $brandagent_attrs,
717 'timestamp' => current_time( 'c' ),
718 );
719 }
720
721 // Build line items array similar to order webhook format
722 $line_items = array();
723
724 foreach ( $cart->get_cart() as $cart_item_key => $cart_item ) {
725 $product = $cart_item['data'];
726 $parent_product = null;
727 $variant_title = '';
728
729 // Get parent product and variant title if this is a variation
730 if ( $cart_item['variation_id'] ) {
731 $parent_product = wc_get_product( $cart_item['product_id'] );
732 $variant_title = $product ? $product->get_name() : '';
733 }
734
735 $line_items[] = array(
736 'key' => $cart_item_key,
737 'product_id' => $cart_item['product_id'],
738 'product_title' => $parent_product ? $parent_product->get_name() : ( $product ? $product->get_name() : '' ),
739 'variation_id' => $cart_item['variation_id'],
740 'variant_title' => $variant_title,
741 'sku' => $product ? $product->get_sku() : '',
742 'quantity' => $cart_item['quantity'],
743 'price' => $product ? $product->get_price() : 0,
744 'line_total' => $cart_item['line_total'],
745 'line_subtotal' => $cart_item['line_subtotal'],
746 'line_tax' => $cart_item['line_tax'] ?? 0,
747 );
748 }
749
750 // Get customer info
751 $customer = WC()->customer;
752 $customer_data = null;
753
754 if ( $customer ) {
755 $customer_data = array(
756 'id' => $customer->get_id(),
757 'email' => $customer->get_email(),
758 'first_name' => $customer->get_first_name(),
759 'last_name' => $customer->get_last_name(),
760 'billing_address' => array(
761 'first_name' => $customer->get_billing_first_name(),
762 'last_name' => $customer->get_billing_last_name(),
763 'company' => $customer->get_billing_company(),
764 'address_1' => $customer->get_billing_address_1(),
765 'address_2' => $customer->get_billing_address_2(),
766 'city' => $customer->get_billing_city(),
767 'state' => $customer->get_billing_state(),
768 'postcode' => $customer->get_billing_postcode(),
769 'country' => $customer->get_billing_country(),
770 'email' => $customer->get_billing_email(),
771 'phone' => $customer->get_billing_phone(),
772 ),
773 'shipping_address' => array(
774 'first_name' => $customer->get_shipping_first_name(),
775 'last_name' => $customer->get_shipping_last_name(),
776 'company' => $customer->get_shipping_company(),
777 'address_1' => $customer->get_shipping_address_1(),
778 'address_2' => $customer->get_shipping_address_2(),
779 'city' => $customer->get_shipping_city(),
780 'state' => $customer->get_shipping_state(),
781 'postcode' => $customer->get_shipping_postcode(),
782 'country' => $customer->get_shipping_country(),
783 ),
784 );
785 }
786
787 // Get applied coupons
788 $coupons = array();
789 foreach ( $cart->get_applied_coupons() as $coupon_code ) {
790 $coupon = new WC_Coupon( $coupon_code );
791 $coupons[] = array(
792 'code' => $coupon_code,
793 'discount' => $cart->get_coupon_discount_amount( $coupon_code ),
794 'discount_tax' => $cart->get_coupon_discount_tax_amount( $coupon_code ),
795 );
796 }
797
798 return array(
799 'event' => 'checkout.created',
800 'cart_id' => $cart_id,
801 'currency' => $currency,
802 'checkout_url' => wc_get_checkout_url(),
803 'line_items' => $line_items,
804 'line_items_count' => count( $line_items ),
805 'cart' => array(
806 'item_count' => $cart->get_cart_contents_count(),
807 'total' => $cart->get_total( 'edit' ),
808 'subtotal' => $cart->get_subtotal(),
809 'tax_total' => $cart->get_cart_contents_tax(),
810 'shipping_total' => $cart->get_shipping_total(),
811 'shipping_tax' => $cart->get_shipping_tax(),
812 'discount_total' => $cart->get_discount_total(),
813 'discount_tax' => $cart->get_discount_tax(),
814 'fees_total' => $cart->get_fee_total(),
815 'fees_tax' => $cart->get_fee_tax(),
816 ),
817 'coupons' => $coupons,
818 'customer' => $customer_data,
819 'session_id' => ! empty( $brandagent_attrs['sessionId'] )
820 ? $brandagent_attrs['sessionId']
821 : $cart_id,
822 'brandagent_attributes' => $brandagent_attrs,
823 'timestamp' => current_time( 'c' ),
824 );
825 }
826 add_filter( 'woocommerce_webhook_payload', 'brandagent_checkout_webhook_payload', 10, 4 );
827
828 /**
829 * Trigger checkout.created webhooks when user visits checkout page
830 *
831 * Note: WooCommerce checkout URL is configurable and not always at /checkout.
832 * We use is_checkout() to properly detect the checkout page.
833 */
834 class BrandAgent_Checkout_Webhook_Trigger {
835
836 /**
837 * Track if webhook has already been triggered for this request
838 * @var bool
839 */
840 private static $triggered = false;
841
842 /**
843 * Initialize checkout webhook triggers
844 */
845 public static function init() {
846 // Only register hooks if BrandAgent is enabled to avoid unnecessary processing
847 if ( get_option( 'BAInjectFrontendScript' ) !== 'true' ) {
848 return;
849 }
850
851 // Hook into template_redirect to detect checkout page visits
852 // This fires after WooCommerce has set up the checkout page context
853 add_action( 'template_redirect', array( __CLASS__, 'on_checkout_page' ), 20 );
854 }
855
856 /**
857 * Handle checkout page visit
858 */
859 public static function on_checkout_page() {
860 // Only trigger on checkout page (not cart, not order-received endpoint)
861 if ( ! is_checkout() || is_order_received_page() || is_checkout_pay_page() ) {
862 return;
863 }
864
865 // Prevent duplicate triggers
866 if ( self::$triggered ) {
867 return;
868 }
869
870 // Don't trigger for empty carts
871 if ( ! WC()->cart || WC()->cart->is_empty() ) {
872 return;
873 }
874
875 self::$triggered = true;
876 self::trigger_webhooks();
877 }
878
879 /**
880 * Trigger all checkout.started webhooks
881 */
882 private static function trigger_webhooks() {
883 // Find all active checkout.started webhooks
884 $webhooks = brandagent_get_webhooks_by_topic( 'checkout.created' );
885
886 foreach ( $webhooks as $webhook ) {
887 if ( $webhook->get_status() === 'active' ) {
888 brandagent_deliver_custom_webhook( $webhook, 'checkout', 'checkout.created', 'created' );
889 }
890 }
891 }
892 }
893
894 // Initialize checkout webhook triggers when WooCommerce is loaded
895 add_action( 'woocommerce_loaded', array( 'BrandAgent_Checkout_Webhook_Trigger', 'init' ) );
896