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 |