PluginProbe
Email Marketing for WordPress and WooCommerce – Retainful / trunk
Email Marketing for WordPress and WooCommerce – Retainful vtrunk
1.0.9 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8
retainful / App / Controllers / Webhook.php

Webhook.php in Email Marketing for WordPress and WooCommerce – Retainful trunk, at App/Controllers/Webhook.php

1,138 lines 42.4 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace RTFL\App\Controllers;
4
5 use RTFL\App\Controllers\Payloads\AbandonedCart;
6 use RTFL\App\Controllers\Payloads\Category;
7 use RTFL\App\Controllers\Payloads\Orders;
8 use RTFL\App\Controllers\Payloads\Products;
9 use RTFL\App\Controllers\Payloads\ViewedProduct;
10 use RTFL\App\Helpers\Common;
11 use RTFL\App\Helpers\Customer;
12 use RTFL\App\Helpers\Dispatch;
13 use RTFL\App\Helpers\Input;
14 use RTFL\App\Helpers\Order;
15 use RTFL\App\Helpers\Platform;
16 use RTFL\App\Helpers\Product;
17 use RTFL\App\Helpers\Settings;
18 use RTFL\App\Helpers\Util;
19
20 defined( "ABSPATH" ) or exit;
21
22 class Webhook implements Controller {
23 public static function register() {
24
25 //validate the webhook register
26 add_filter( 'woocommerce_webhook_topics', [ self::class, 'addCustomWebhookTopics' ], 10000 );
27 add_filter( 'woocommerce_valid_webhook_events', [ self::class, 'addCustomWebhookEvent' ], 10000 );
28 add_filter( 'woocommerce_valid_webhook_resources', [ self::class, 'addCustomWebhookResource' ], 10000 );
29
30 // Raise the default webhook failure threshold from 5 to 25.
31 // WooCommerce auto-disables webhooks after this many consecutive
32 // delivery failures. 5 is too aggressive for transient API issues.
33 add_filter( 'woocommerce_max_webhook_delivery_failures', function () {
34 return 25;
35 } );
36
37 //change payload filters
38 //add_filter( 'woocommerce_webhook_payload', [ self::class, 'changeWebhookPayload' ], 10, 4 );
39 add_filter( 'woocommerce_webhook_http_args', [ self::class, 'changeWebhookPayload' ], 11, 3 );
40 add_filter( 'woocommerce_webhook_http_args', [ self::class, 'changeWebHookHeader' ], 10, 3 );
41 add_filter( 'woocommerce_webhook_should_deliver', [ Products::class, 'shouldDeliverProductWebhook' ], 10, 4 );
42
43 //category webhook trigger
44 add_action( 'created_product_cat', function ( $term_id, $taxonomy ) {
45 self::categoryWebhookTrigger( 'updated', $term_id, $taxonomy );
46 }, 10, 2 );
47 add_action( 'edited_product_cat', function ( $term_id, $taxonomy ) {
48 self::categoryWebhookTrigger( 'updated', $term_id, $taxonomy );
49 }, 10, 2 );
50 add_action( 'delete_product_cat', function ( $term_id, $taxonomy ) {
51 self::categoryWebhookTrigger( 'deleted', $term_id, $taxonomy );
52 }, 10, 2 );
53 add_action( 'retainful_category', [ self::class, 'categoryWebhookDelivery' ] );
54
55 // Product Triggers
56 /*foreach ( Products::getHooks( 'product.created' ) as $hook ) {
57 add_action( $hook, [ self::class, 'scheduleProductCreateSync' ] );
58 }
59 add_action('transition_post_status',function($new_status, $old_status, $post){
60 if($post->post_type === 'product' && $new_status === 'publish' && $old_status !== 'publish'){
61 self::scheduleProductCreateSync($post->ID);
62 }
63 },10,3);*/
64
65 // Product create/update/delete events are disabled — do not re-enable
66 // without also re-checking topics() and addCustomWebhookTopics() below.
67 /*foreach ( Products::getHooks( 'product.updated' ) as $hook ) {
68 add_action( $hook, [ self::class, 'scheduleProductUpdateSync' ] );
69 }
70
71 foreach ( Products::getHooks( 'product.deleted' ) as $hook ) {
72 add_action( $hook, [ self::class, 'scheduleProductDeleteSync' ] );
73 }
74 add_action( 'retainful_product_created_sync', [ self::class, 'handleProductSync' ], 10 );
75 add_action( 'retainful_product_updated_sync', [ self::class, 'handleProductSync' ], 10 );
76 add_action( 'retainful_product_deleted_sync', [ self::class, 'handleProductDeleteSync' ] );*/
77
78 //Track Product
79 add_action( 'retainful_track_product_viewed', [ self::class, 'productTrackWebhookDelivery' ], 10 );
80 add_action( 'wp_ajax_rtfl_track_product_viewed', [ self::class, 'handleProductViewedTracking' ] );
81 add_action( 'wp_ajax_nopriv_rtfl_track_product_viewed', [ self::class, 'handleProductViewedTracking' ] );
82
83
84
85 //Track cart
86 add_action( 'retainful_checkout_started_sync', [ self::class, 'abandonedCartTrackWebhookTrigger' ] );
87
88
89 //trigger the order status change
90 add_action( 'retainful_order_status_change', [ self::class, 'deliverOrderStatusWebHook' ] );
91 }
92
93 /**
94 * Periodic health check: re-enable any Retainful webhooks that
95 * WooCommerce auto-disabled after consecutive delivery failures,
96 * and reset their failure counters so they get a fresh start.
97 *
98 * Scheduled via Action Scheduler (daily) from Route::init().
99 */
100 public static function webhookHealthCheck() {
101 $webhooks = Settings::getRetainfulOption( 'webhooks', 'woocommerce' );
102 if ( empty( $webhooks ) || ! is_array( $webhooks ) || ! function_exists( 'wc_get_webhook' ) ) {
103 return;
104 }
105
106 foreach ( $webhooks as $topic => $webhook_id ) {
107 if ( empty( $webhook_id ) ) {
108 continue;
109 }
110 $webhook = wc_get_webhook( $webhook_id );
111 if ( empty( $webhook ) || ! Util::isMethodExists( $webhook, 'get_status' ) ) {
112 continue;
113 }
114 if($webhook->get_topic() === 'retainful_product.retainful_created'){//$webhook->get_topic() === 'retainful_product.retainful_created'
115 $webhook->delete();
116 unset($webhooks[$topic]);
117 $options = Settings::getRetainfulPluginOption();
118 $options['webhooks'] = $webhooks;
119 Settings::updateRetainfulOptionSettings($options);
120 continue;
121 }
122 if ( $webhook->get_status() !== 'active' ) {
123 // Re-enable the webhook.
124 $webhook->set_status( 'active' );
125 if(Util::isMethodExists($webhook,'set_failure_count')){
126 $webhook->set_failure_count(0);
127 }
128 $webhook->save();
129
130 Settings::logMessage(
131 sprintf( 'Re-enabled auto-disabled webhook #%d (topic: %s) and reset failure count.', $webhook_id, $topic ),
132 'webhook_health_check'
133 );
134 }
135 }
136 }
137
138 public static function topics() {
139 $webhook_names = [
140 // Product create/update/delete webhooks are disabled — commented
141 // out so registerWebhooks() never provisions them for new/re-synced sites.
142 //'retainful_product.retainful_created' => __( 'Retainful Product Created', 'retainful' ),
143 //'retainful_product.retainful_updated' => __( 'Retainful Product Updated', 'retainful' ),
144 //'retainful_product.retainful_deleted' => __( 'Retainful Product Deleted', 'retainful' ),
145 'retainful_product.retainful_viewed' => __( 'Retainful Product viewed', 'retainful' ),
146
147 'category.retainful_created' => __( 'Retainful Category Created', 'retainful' ),
148 'category.retainful_deleted' => __( 'Retainful Category Deleted', 'retainful' ),
149
150 //order sync webhook
151 'checkout.retainful_started' => __( 'Retainful checkout started', 'retainful' ),
152 'retainful_order.retainful_placed' => __( 'Retainful Order Placed', 'retainful' ),
153 'retainful_order.retainful_fulfilled' => __( 'Retainful Order Fulfilled', 'retainful' ),
154 'retainful_order.retainful_paid' => __( 'Retainful Order Paid', 'retainful' ),
155 'retainful_order.retainful_cancelled' => __( 'Retainful Order Status Cancelled', 'retainful' ),
156 'retainful_order.retainful_refunded' => __( 'Retainful Order Status Refunded', 'retainful' )
157 ];
158
159 return apply_filters( 'retainful_webhook_topics', $webhook_names );
160 }
161
162 public static function changeWebHookHeader( $http_args, $order_id, $webhook_id ) {
163 if ( $webhook_id <= 0 || ! class_exists( 'WC_Webhook' ) || ! Settings::hasWebhookId( $webhook_id ) ) {
164 return $http_args;
165 }
166 try {
167 $webhook = new \WC_Webhook( $webhook_id );
168 $resource = Util::isMethodExists( $webhook, 'get_resource' ) ? $webhook->get_resource() : '';
169 if ( empty( $resource ) ) {
170 return $http_args;
171 }
172 switch ( $resource ) {
173 case 'checkout':
174 $http_args = AbandonedCart::changeWebhookHeader( $http_args, $order_id, $webhook_id, $resource );
175 break;
176 case 'order':
177 case 'retainful_order':
178 $http_args = Orders::changeWebhookHeader( $http_args, $order_id, $webhook_id, $resource );
179 break;
180 case 'product':
181 case 'retainful_product':
182 $http_args = Products::changeWebhookHeader( $http_args, $order_id, $webhook_id, $resource );
183 break;
184 case 'category':
185 $http_args = Category::changeWebhookHeader( $http_args, $order_id, $webhook_id, $resource );
186 break;
187 default:
188 }
189 } catch ( \Exception $e ) {
190
191 }
192
193 return $http_args;
194 }
195
196 public static function addCustomWebhookEvent( $events ) {
197 $new_events = [
198 'retainful_created',
199 'retainful_updated',
200 'retainful_deleted',
201 'retainful_cancelled',
202 'retainful_fulfilled',
203 'retainful_paid',
204 'retainful_refunded',
205 'retainful_placed',
206 'retainful_started',
207 'retainful_viewed',
208 ];
209
210 return array_merge( $events, $new_events );
211
212 }
213
214 public static function addCustomWebhookResource( $resources ) {
215 return array_merge( $resources, [ 'category', 'checkout', 'retainful_product', 'retainful_order' ] );
216 }
217
218 public static function addCustomWebhookTopics( $topics ) {
219 $new_topics = [
220 //'retainful_product.retainful_created' => __( 'Retainful Product Created', 'retainful' ),
221 //'retainful_product.retainful_updated' => __( 'Retainful Product Updated', 'retainful' ),
222 //'retainful_product.retainful_deleted' => __( 'Retainful Product Deleted', 'retainful' ),
223 'retainful_product.retainful_viewed' => __( 'Retainful Product Viewed', 'retainful' ),
224 //
225 'category.retainful_created' => __( 'Retainful Category Created', 'retainful' ),
226 'category.retainful_deleted' => __( 'Retainful Category Deleted', 'retainful' ),
227
228
229 //order sync webhook
230 'checkout.retainful_started' => __( 'Retainful checkout started', 'retainful' ),
231 'retainful_order.retainful_placed' => __( 'Retainful Order Placed', 'retainful' ),
232 'retainful_order.retainful_fulfilled' => __( 'Retainful Order Fulfilled', 'retainful' ),
233 'retainful_order.retainful_paid' => __( 'Retainful Order Paid', 'retainful' ),
234 'retainful_order.retainful_cancelled' => __( 'Retainful Order Status Cancelled', 'retainful' ),
235 'retainful_order.retainful_refunded' => __( 'Retainful Order Status Refunded', 'retainful' )
236 ];
237
238 return array_merge( $topics, $new_topics );
239 }
240
241 public static function getWebhookUrls( $received_webhook_urls ) {
242 if ( empty( $received_webhook_urls ) || ! is_array( $received_webhook_urls ) ) {
243 return [];
244 }
245
246 $mapping = [
247 // Product create/update/delete webhooks are disabled — see register().
248 //'retainful_product.retainful_created' => 'product.created',
249 //'retainful_product.retainful_updated' => 'product.updated',
250 //'retainful_product.retainful_deleted' => 'product.deleted',
251 'retainful_product.retainful_viewed' => 'product.viewed',
252 'category.retainful_created' => 'category.created',
253 'category.retainful_deleted' => 'category.deleted',
254 'checkout.retainful_started' => 'checkout.started',
255 'retainful_order.retainful_placed' => 'order.placed',
256 'retainful_order.retainful_fulfilled' => 'order.fulfilled',
257 'retainful_order.retainful_paid' => 'order.paid',
258 'retainful_order.retainful_cancelled' => 'order.cancelled',
259 'retainful_order.retainful_refunded' => 'order.refunded',
260 ];
261 $webhook_urls = [];
262 foreach ( self::topics() as $topic => $name ) {
263 if ( ! isset( $mapping[ $topic ] ) ) {
264 continue;
265 }
266 $webhook_urls[ $topic ] = esc_url_raw( trim( $received_webhook_urls[ $mapping[ $topic ] ] ) );
267 }
268
269 return $webhook_urls;
270 }
271 public static function getWebhookEventTopics( $event_type = '' ) {
272 $mapping = [
273 'retainful_product.retainful_created' => 'product.created',
274 'retainful_product.retainful_updated' => 'product.updated',
275 'retainful_product.retainful_deleted' => 'product.deleted',
276 'retainful_product.retainful_viewed' => 'product.viewed',
277 'category.retainful_created' => 'category.created',
278 'category.retainful_deleted' => 'category.deleted',
279 'checkout.retainful_started' => 'checkout.started',
280 'retainful_order.retainful_placed' => 'orders.placed',
281 'retainful_order.retainful_fulfilled' => 'orders.fulfilled',
282 'retainful_order.retainful_paid' => 'orders.paid',
283 'retainful_order.retainful_cancelled' => 'orders.cancelled',
284 'retainful_order.retainful_refunded' => 'orders.refunded',
285 ];
286
287 if ( empty( $event_type ) ) {
288 return $mapping;
289 }
290
291 if ( isset( $mapping[ $event_type ] ) ) {
292 return $mapping[ $event_type ];
293 }
294 return null;
295 }
296
297
298 public static function categoryWebhookDelivery( $args ) {
299 $term_id = $args['term_id'] ?? 0;
300 $action = $args['action'] ?? '';
301 if ( empty( $term_id ) || ! class_exists( 'WC_Data_Store' ) || ! function_exists( 'wc_get_webhook' ) ) {
302 return;
303 }
304
305 // Map 'updated' action to 'created' since both use the same webhook topic
306 $topic_action = ( $action === 'updated' ) ? 'retainful_created' : 'retainful_' . $action;
307 $topics = [ 'category.' . $topic_action ];
308
309 // Custom topic matcher for category webhooks
310 $topic_matcher = function ( $webhook_topic, $expected_topic ) use ( $topic_action ) {
311 return strpos( $webhook_topic, 'category' ) !== false && $webhook_topic === "category.{$topic_action}";
312 };
313
314 Dispatch::deliverWebhook( $topics, $args, $topic_matcher );
315 }
316
317 public static function categoryWebhookTrigger( $action, $term_id, $taxonomy ) {
318 // Map 'updated' action to 'created' since both use the same webhook topic
319 $webhook_action = ( $action === 'updated' ) ? 'retainful_created' : 'retainful_' . $action;
320 $webhooks = Settings::getRetainfulOption( 'webhooks', 'woocommerce' );
321 if ( ! empty( $webhooks ) && ! empty( $webhooks[ 'category.' . $webhook_action ] ) ) {
322 $webhook = wc_get_webhook( $webhooks[ 'category.' . $webhook_action ] );
323 $webhook_status = Util::isMethodExists( $webhook, 'get_status' ) ? $webhook->get_status() : '';
324 if ( $webhook_status !== 'active' ) {
325 return;
326 }
327 }
328
329 $action_args = [
330 'args' => [
331 'term_id' => $term_id,
332 'action' => $action
333 ]
334 ];
335
336 $callback = function ( $args ) {
337 self::categoryWebhookDelivery( $args['args'] ?? [] );
338 };
339
340 Dispatch::dispatchAction( 'retainful_category', $action_args, $callback, 5*60, false );// wait 5 minutes, then all updated data sync once
341 }
342
343 public static function changeWebhookPayload( $http_args, $args, $webhook_id ) {
344 if ( $webhook_id <= 0 || ! class_exists( 'WC_Webhook' ) || ! Settings::hasWebhookId( $webhook_id ) ) {
345 return $http_args;
346 }
347 $webhook = function_exists( 'wc_get_webhook' ) ? wc_get_webhook( $webhook_id ) : '';
348 if ( empty( $webhook ) ) {
349 return $http_args;
350 }
351 $resource = Util::isMethodExists( $webhook, 'get_resource' ) ? $webhook->get_resource() : '';
352 $topic = Util::isMethodExists( $webhook, 'get_topic' ) ? $webhook->get_topic() : '';
353 try {
354 switch ( $resource ) {
355 case 'retainful_product':
356 if ( $topic == 'retainful_product.retainful_viewed' ) {
357 $http_args['body'] = ViewedProduct::getModifiedPayLoadData( $args, $webhook_id );
358 Settings::logMessage( 'Product ' . $topic . ' payload: ' . $http_args['body'] );
359 }
360 // Product create/update/delete payload building is disabled —
361 // Products::getModifiedPayLoadData() below has its declaration commented out,
362 // so this branch is commented out too rather than left as a dangling call.
363 /*
364 else if ( $topic == 'retainful_product.retainful_deleted' ) {
365 $http_args['body'] = trim( wp_json_encode( [ 'id' => $args['product_id'] ?? 0 ] ) );
366 } else {
367 $http_args['body'] = trim( wp_json_encode( Products::getModifiedPayLoadData( $args, $webhook_id ) ) );
368 }
369 Settings::logMessage( 'Product ' . $topic . ' payload: ' . $http_args['body'] );
370 */
371 break;
372 case 'category':
373 $http_args['body'] = trim( wp_json_encode( Category::getModifiedPayLoadData( $args, $webhook_id ) ) );
374 Settings::logMessage( 'Category ' . $topic . ' payload: ' . $http_args['body'] );
375 break;
376 case 'checkout':
377 $http_args['body'] = AbandonedCart::getModifiedPayLoadData( $args, $webhook_id );
378 Settings::logMessage( 'Checkout ' . $topic . ' payload: ' . $http_args['body'] );
379 break;
380 case 'retainful_order':
381 $http_args['body'] = trim( wp_json_encode( Orders::getModifiedPayLoadData( $args, $webhook_id ) ) );
382 Settings::logMessage( 'Order ' . $topic . ' payload: ' . $http_args['body'] );
383 }
384 } catch ( \Exception $exception ) {
385
386 }
387
388 return $http_args;
389 }
390
391 /*public static function changeWebhookPayload( $payload, $resource, $resource_id, $webhook_id ) {
392 Settings::logMessage( 'Change Payload Called' );
393 // Return original payload if resource is empty
394 if ( empty( $resource ) ) {
395 return $payload;
396 }
397
398 try {
399 switch ( $resource ) {
400 case 'category' :
401 $payload = Category::changePayloadData( $payload, $resource, $resource_id, $webhook_id );
402 break;
403 case 'retainful_product':
404 $webhook = function_exists( 'wc_get_webhook' ) ? wc_get_webhook( $webhook_id ) : '';
405 if ( empty( $webhook ) ) {
406 Settings::logMessage( 'Webhook not found for ID: ' . $webhook_id, 'Product change payload' );
407
408 return $payload;
409 }
410 Settings::logMessage( $payload, 'Product income payload:' );
411 $topic = Util::isMethodExists( $webhook, 'get_topic' ) ? $webhook->get_topic() : '';
412 if ( $topic == 'product.retainful_viewed' ) {
413 $payload = ViewedProduct::changePayloadData( $payload, $resource, $resource_id, $webhook_id );
414 } else {
415 $payload = Products::changePayloadData( $payload, $resource, $resource_id, $webhook_id );
416 }
417 Settings::logMessage( $payload, 'Product:' . $topic );
418 break;
419 case 'checkout':
420 $payload = AbandonedCart::changePayloadData( $payload, $resource, $resource_id, $webhook_id );
421 break;
422 case 'order':
423 $payload = Orders::changePayloadData( $payload, $resource, $resource_id, $webhook_id );
424 break;
425 }
426 } catch ( \Exception $e ) {
427 Settings::logMessage( $e->getMessage(), 'error in generate yuko payload' );
428 }
429
430 return $payload;
431 }*/
432
433
434 public static function registerWebhooks( $webhook_url, $secret_key ) {
435 if ( empty( $webhook_url ) || empty( $secret_key ) ) {
436 return [];
437 }
438 $webhooks = [];
439 foreach ( self::topics() as $topic => $name ) {
440 $webhook_id = self::registerOrderCreatedWebhook( $topic, $webhook_url, $secret_key );
441 if ( empty( $webhook_id ) ) {
442 self::deletePreviousWebhooksForThisOrganization( $secret_key );
443
444 return [];
445 }
446 $webhooks[ $topic ] = $webhook_id;
447 }
448
449 return array_filter( $webhooks );
450 }
451
452
453 public static function registerOrderCreatedWebhook( $topic, $webhook_url, $secret_key ) {
454 if ( ! class_exists( 'WC_Webhook' ) || empty( $topic ) || empty( $webhook_url ) || empty( $secret_key ) ) {
455 return 0; // Exit if WooCommerce is not active.
456 }
457
458 global $wpdb;
459 $webhook_id = self::getWebhookID( $topic );
460 if ( ! empty( $webhook_id ) ) {
461 return $webhook_id;
462 }
463
464 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Checking webhook existence before creation
465 $existing_id = $wpdb->get_var(
466 $wpdb->prepare( "SELECT webhook_id FROM {$wpdb->prefix}wc_webhooks WHERE topic = %s AND delivery_url =%s", $topic, $webhook_url )
467 );
468 if ( ! empty( $existing_id ) && $existing_id > 0 ) {
469 return absint( $existing_id ); // Webhook already exists for this topic.
470 }
471
472 // Create a new webhook.
473 $webhook = new \WC_Webhook();
474 $webhook_topic = self::topics();
475 $webhook->set_name( $webhook_topic[ $topic ] ); // Set the webhook name.
476 $webhook->set_user_id( get_current_user_id() ); //set the current user_id
477 $webhook->set_status( 'active' ); // Can be 'active', 'paused', or 'disabled'.
478 $webhook->set_topic( $topic ); // Define the event topic.
479 $webhook->set_delivery_url( $webhook_url[ $topic ] ); // Set your delivery URL.
480 $webhook->set_secret( $secret_key ); // Optional: Set a secret key for validation.
481 $webhook->set_api_version( 2 ); // Set the WooCommerce REST API version.
482
483 return $webhook->save(); // Save the webhook.
484 }
485
486 public static function getWebhookID( $topic ) {
487 $data = Settings::getRetainfulOption( 'webhooks', 'woocommerce' );
488 if ( empty( $data ) || ! is_array( $data ) ) {
489 return null;
490 }
491
492 return ! empty( $data[ $topic ] ) ? $data[ $topic ] : '';
493 }
494
495 public static function deletePreviousWebhooksForThisOrganization( $secret_key ) {
496 global $wpdb;
497
498 // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Deleting webhooks by secret key, uses wpdb->delete with prepared values
499 return $wpdb->delete( "{$wpdb->prefix}wc_webhooks", [ 'secret' => $secret_key ] );
500 }
501
502 /**
503 * AJAX: track a "product viewed" event for an identified customer.
504 *
505 * Replaces the old template_redirect + Action Scheduler approach.
506 * This AJAX call IS the async delivery — no extra scheduling needed.
507 */
508 public static function handleProductViewedTracking(): void {
509 // 1. Security
510 if ( ! \RTFL\App\Helpers\Common::verifyNonce( Input::get( 'nonce', '' ), 'rtfl-cart-sync' ) ) {
511 wp_send_json_error( [ 'message' => __( 'Security check failed.', 'retainful' ) ], 403 );
512 }
513 $isEnabled = (bool)Settings::getAdminSetting( RTFL_PLUGIN_PREFIX . 'enable_product_view' );
514 if( ! $isEnabled ) {
515 wp_send_json_error( [ 'message' => __( 'Product View track disabled.', 'retainful' ) ], 403 );
516 }
517
518 // 2. Input validation
519 $product_id = absint( Input::get( 'product_id', 0 ) );
520 $email = sanitize_email( Input::get( 'email', '' ) );
521
522 if ( ! $product_id || ! is_email( $email ) ) {
523 wp_send_json_error( [ 'message' => __( 'Invalid parameters.', 'retainful' ) ], 400 );
524 }
525
526 // 3. Check webhook is configured and active before doing any work
527 $webhooks = Settings::getRetainfulOption( 'webhooks', 'woocommerce' );
528 $webhook_id = $webhooks['retainful_product.retainful_viewed'] ?? 0;
529 if ( empty( $webhook_id ) || ! function_exists( 'wc_get_webhook' ) ) {
530 wp_send_json_success( [ 'message' => 'Webhook not configured.' ] );
531 }
532 $webhook = wc_get_webhook( $webhook_id );
533 if ( empty( $webhook ) || $webhook->get_status() !== 'active' ) {
534 wp_send_json_success( [ 'message' => 'Webhook inactive.' ] );
535 }
536
537 // Step 3: Get or create the track product cookie ID
538 $track_product_cookie_id = ViewedProduct::getTrackProductCookieID( $email );
539 if ( empty( $track_product_cookie_id ) ) {
540 wp_send_json_success( [ 'message' => 'Track key invalid.' ] );
541 }
542
543 // Step 4: Set the viewed product data
544 ViewedProduct::setTrackProduct( $track_product_cookie_id, (int) $product_id, $email );
545
546 $action_args = [ 'args' => [ /*'product_id' => (int) $product_id,*/ "session_id" => $track_product_cookie_id] ];
547 $callback = function ( $action_args ) {
548 self::productTrackWebhookDelivery( $action_args['args'] ?? [] );
549 };
550
551 // Step 6: Create scheduled action to deliver the webhook
552 $dedupe_key = 'rtfl_viewed_' . $track_product_cookie_id;
553 if ( get_transient( $dedupe_key ) ) {
554 wp_send_json_success( [ 'message' => 'Track product updated.' ] );
555 }
556
557 set_transient( $dedupe_key, 1, apply_filters( RTFL_PLUGIN_PREFIX.'viewed_product_dispatch_dedup_minutes', (2*24*60) ) * 60 );
558 Dispatch::dispatchAction( 'retainful_track_product_viewed', $action_args, $callback, 300, false );
559 wp_send_json_success( [ 'message' => 'Tracked.' ] );
560 }
561
562 /**
563 * Get a stable session UUID for this customer for today.
564 * Reuses the same UUID across multiple product views in the same day,
565 * so the SaaS side sees one session with multiple products rather than
566 * N separate sessions.
567 */
568 private static function getOrCreateViewedProductSessionId( string $email ): string {
569 $key = RTFL_PLUGIN_PREFIX . 'pv_session_' . md5( $email . gmdate( 'Y-m-d' ) );
570 $existing = get_transient( $key );
571 if ( $existing ) {
572 return $existing;
573 }
574 $session_id = \RTFL\App\Helpers\Platform::generateUuid();
575 set_transient( $key, $session_id, DAY_IN_SECONDS );
576 return $session_id;
577 }
578
579 /**
580 * Merge the newly-viewed product into the session's running product list.
581 * Increments viewed_count if the product was already seen this session.
582 */
583 private static function mergeProductIntoSession( string $session_id, string $email, array $new_product ): array {
584 // Read existing session payload (if customer viewed products earlier today)
585 $existing = Platform::getTransient( $session_id );
586 $products = ( is_array( $existing ) && isset( $existing['products'] ) )
587 ? $existing['products']
588 : [];
589
590 $variant_id = $new_product['variantId'] ?? 0;
591 $found = false;
592 foreach ( $products as &$p ) {
593 if ( isset( $p['variantId'] ) && (int) $p['variantId'] === (int) $variant_id ) {
594 $p['viewed_count'] = ( (int) ( $p['viewed_count'] ?? 1 ) ) + 1;
595 $p['viewedAt'] = current_time( 'Y-m-d H:i:s' );
596 $found = true;
597 break;
598 }
599 }
600 unset( $p );
601
602 if ( ! $found ) {
603 $products[] = $new_product;
604 }
605
606 $payload = \RTFL\App\Controllers\Payloads\ViewedProduct::buildPayloadData(
607 $session_id,
608 $email,
609 [ 'products' => $products ]
610 );
611
612 // Persist merged session (extends TTL on each new view — sessions last 24 h from last activity)
613 Platform::setTransient( $session_id, $payload, DAY_IN_SECONDS );
614
615 return $payload;
616 }
617
618 /*
619 public static function scheduleProductCreateSync( $product_id ) {
620 $topic = 'retainful_product.retainful_created';
621 $payload = Products::getProductSyncActionData( $topic, $product_id );
622 if ( empty( $payload ) ) {
623 return;
624 }
625
626 $callback = function ( $payload ) {
627 self::handleProductSync( $payload['args'] ?? [] );
628 };
629
630 // Step 6: Create scheduled action to deliver the webhook
631 Dispatch::dispatchAction( 'retainful_product_created_sync', $payload, $callback, 10, false, true );
632 }
633
634 public static function scheduleProductUpdateSync( $product_id ) {
635 static $scheduled_products = [];
636
637 $parent_id = Products::getParentProductId( $product_id );
638 if ( isset( $scheduled_products[ $parent_id ] ) ) {
639 return;
640 }
641
642 // Check the last sync timestamp
643 $should_check_interval = apply_filters(
644 'retainful_product_sync_check_interval',
645 true,
646 $parent_id
647 );
648
649 if ( $should_check_interval && function_exists( 'get_post_meta' ) ) {
650 $last_sync_time = get_post_meta( $parent_id, '_rtfl_product_last_sync_time', true );
651 if ( ! empty( $last_sync_time ) ) {
652 $time_diff = time() - intval( $last_sync_time );
653 $interval_limit = 7 * DAY_IN_SECONDS; // 7 days in seconds
654 if ( $time_diff < $interval_limit ) {
655 return;
656 }
657 }
658 }
659
660 $scheduled_products[ $parent_id ] = true;
661
662 $topic = 'retainful_product.retainful_updated';
663 $payload = Products::getProductSyncActionData( $topic, $product_id );
664 if ( empty( $payload ) ) {
665 return;
666 }
667 $callback = function ( $payload ) {
668 self::handleProductSync( $payload['args'] ?? [] );
669 };
670
671 // Step 6: Create scheduled action to deliver the webhook
672 Dispatch::dispatchAction( 'retainful_product_updated_sync', $payload, $callback, 5*60, false, true ); // wait 5 minutes, all updated product data sync once.
673 }
674
675 public static function scheduleProductDeleteSync( $product_id ) {
676 if ( ! Products::isProductPost( $product_id ) ) {
677 return;
678 }
679 $topic = 'retainful_product.retainful_deleted';
680 $payload = Products::getProductSyncActionData( $topic, $product_id );
681 if ( empty( $payload ) ) {
682 return;
683 }
684 $callback = function ( $payload ) {
685 self::handleProductDeleteSync( $payload['args'] ?? [] );
686 };
687
688 // Step 6: Create scheduled action to deliver the webhook
689 Dispatch::dispatchAction( 'retainful_product_deleted_sync', $payload, $callback, 10, false, true );
690 }
691
692 public static function handleProductDeleteSync( $payload ) {
693 $product_id = $payload['product_id'] ?? '';
694 $webhook_id = $payload['webhook_id'] ?? '';
695 if ( empty( $product_id ) || empty( $webhook_id ) || ! function_exists( 'wc_get_webhook' ) ) {
696 return;
697 }
698
699 $webhook = wc_get_webhook( $webhook_id );
700 $webhook_status = Util::isMethodExists( $webhook, 'get_status' ) ? $webhook->get_status() : '';
701 if ( $webhook_status !== 'active' ) {
702 return;
703 }
704 $topic = Util::isMethodExists( $webhook, 'get_topic' ) ? $webhook->get_topic() : '';
705 if ( empty( $topic ) || ! in_array( $topic, [ 'retainful_product.retainful_deleted' ] ) ) {
706 return;
707 }
708 $topics = [ $topic ];
709 Dispatch::deliverWebhook( $topics, $payload );
710 }
711
712 public static function handleProductSync( $payload ) {
713 $product_id = $payload['product_id'] ?? '';
714 $webhook_id = $payload['webhook_id'] ?? '';
715 if ( empty( $product_id ) || empty( $webhook_id ) || ! function_exists( 'wc_get_webhook' ) ) {
716 Settings::logMessage( 'Product Sync: Missing product_id or webhook_id or wc_get_webhook function not found.', 'product_sync_error' );
717
718 return;
719 }
720
721 $webhook = wc_get_webhook( $webhook_id );
722 $webhook_status = Util::isMethodExists( $webhook, 'get_status' ) ? $webhook->get_status() : '';
723 if ( $webhook_status !== 'active' ) {
724 Settings::logMessage( 'Product Sync: Webhook is not active.', 'product_sync_error' );
725
726 return;
727 }
728 $topic = Util::isMethodExists( $webhook, 'get_topic' ) ? $webhook->get_topic() : '';
729 if ( empty( $topic ) || ! in_array( $topic, [
730 'retainful_product.retainful_created',
731 'retainful_product.retainful_updated'
732 ] ) ) {
733 Settings::logMessage( 'Product Sync: Invalid webhook topic.', 'product_sync_error' );
734
735 return;
736 }
737
738 $product = Product::getProduct( $product_id );
739 if ( empty( $product ) ) {
740 Settings::logMessage( 'Product Sync: Product not found for ID ' . $product_id, 'product_sync_error' );
741
742 return;
743 }
744
745
746 $topics = [ $topic ];
747 Dispatch::deliverWebhook( $topics, $payload );
748
749 // On successful sync of product update, update the sync timestamp
750 if ( $topic === 'retainful_product.retainful_updated' && function_exists( 'update_post_meta' ) ) {
751 update_post_meta( $product_id, '_rtfl_product_last_sync_time', time() );
752 }
753 }
754 */
755
756 public static function productTrackWebhookDelivery( $payload, $is_direct = false ) {
757 $isEnabled = (bool)Settings::getAdminSetting( RTFL_PLUGIN_PREFIX . 'enable_product_view' );
758 if( ! $isEnabled ) {
759 return;
760 }
761 //$product_id = $payload['product_id'] ?? '';
762 $session_id = $payload['session_id'] ?? '';
763 if ( empty( $session_id ) || ! class_exists( 'WC_Data_Store' ) || ! function_exists( 'wc_get_webhook' ) ) {
764 return;
765 }
766
767 $topics = [ 'retainful_product.retainful_viewed' ];
768
769 // Store session_id temporarily for payload filter to retrieve
770 // Key includes product_id to avoid conflicts with concurrent requests
771 $webhooks = Settings::getRetainfulOption( 'webhooks', 'woocommerce' );
772
773 if ( empty( $webhooks ) || ! is_array( $webhooks ) || empty( $webhooks['retainful_product.retainful_viewed'] ) ) {
774 return;
775 }
776
777 $webhook_id = $webhooks['retainful_product.retainful_viewed'];
778 $transient_key = 'rtfl_product_viewed_session_' . $webhook_id;
779 $expiry = 300; // 5 minutes - enough time for webhook to process
780
781 // Store session_id in transient for payload filter to retrieve
782 Platform::setTransient( $transient_key, $session_id, $expiry );
783 // Custom topic matcher for product viewed webhooks
784 $topic_matcher = function ( $webhook_topic, $expected_topic ) {
785 return strpos( $webhook_topic, 'retainful_product' ) !== false && $webhook_topic === "retainful_product.retainful_viewed";
786 };
787 Dispatch::deliverWebhook( $topics, $payload, $topic_matcher );
788 }
789
790 /**
791 * Re-entrancy guard for syncCart(). syncCart() is hooked to
792 * woocommerce_cart_updated, but its own body accesses WC()->cart
793 * (via the abandoned cart payload build) which calls
794 * WC_Cart::calculate_totals() — and that fires
795 * woocommerce_cart_updated again. Without this guard the inner
796 * call runs the entire pipeline a second time, doubling
797 * transient writes and creating a small race window between the
798 * two writes for the same cart.
799 *
800 * @var bool
801 */
802 private static $sync_cart_running = false;
803
804 /**
805 * Core cart sync logic that can be called from both AJAX and PHP hooks.
806 *
807 *
808 * @return void Returns true on success, false on failure, or void if called from AJAX.
809 */
810 public static function syncCart() {
811 // Skip on third-party requests that are not Retainful's own.
812 // We previously only checked wp_doing_ajax(), which left a
813 // blind spot for REST API requests because REST does not set
814 // DOING_AJAX. WooCommerce Blocks (cart, checkout, coupon) and
815 // many search/filter plugins use REST endpoints that touch
816 // WC()->cart, which would otherwise fan out into the full
817 // syncCart pipeline on every concurrent request and reproduce
818 // the FiboSearch-style "Premature end of script headers"
819 // 500s under load. REST requests are skipped entirely
820 // because Retainful's own cart-sync flow does not go via
821 // REST.
822 if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
823 return;
824 }
825 $is_ajax = false;
826 if(function_exists('wp_doing_ajax') && wp_doing_ajax()) {
827 $current_ajax_action = Input::get('action', '');
828 if(empty($current_ajax_action) || !in_array($current_ajax_action, ['rnoc_track_user_data'])) {
829 return;
830 }
831 $is_ajax = true;
832
833 // Verify nonce unconditionally for AJAX requests before any other processing (SEC-13)
834 $retainful_nonce = Input::get( 'nonce', '' );
835 if ( ! \RTFL\App\Helpers\Common::verifyNonce( $retainful_nonce, 'rtfl-cart-sync' ) ) {
836 wp_send_json_error( [
837 'message' => __( 'Security check failed', 'retainful' )
838 ], 403 );
839 }
840 }
841 if ( ! function_exists( 'wc_get_webhook' ) || ! function_exists( 'as_next_scheduled_action' ) || ! function_exists( 'as_schedule_single_action' ) ) {
842 if($is_ajax){
843 wp_send_json_success( [
844 'message' => __( 'Webhook/Scheduled action function is not available', 'retainful' )
845 ] );
846 }
847 return;
848 }
849
850 // Re-entrancy guard. The pipeline below accesses WC()->cart
851 // which can trigger another woocommerce_cart_updated and so
852 // recursively call syncCart on the same request. Bail out
853 // of the inner call so we only run the pipeline once.
854 if ( self::$sync_cart_running ) {
855 if($is_ajax){
856 wp_send_json_success( [
857 'message' => __( 'Cart sync still running', 'retainful' )
858 ] );
859 }
860 return;
861 }
862 self::$sync_cart_running = true;
863
864 try {
865 $webhooks = Settings::getRetainfulOption( 'webhooks', 'woocommerce' );
866 if ( ! empty( $webhooks ) && ! empty( $webhooks['checkout.retainful_started'] ) ) {
867 $webhook = wc_get_webhook( $webhooks['checkout.retainful_started'] );
868 $webhook_id = Util::isMethodExists( $webhook, 'get_id' ) ? $webhook->get_id() : '';
869 $webhook_status = Util::isMethodExists( $webhook, 'get_status' ) ? $webhook->get_status() : '';
870 if ( empty( $webhook_id ) || ! Settings::hasWebhookId( $webhook_id ) || $webhook_status !== 'active' ) {
871 if($is_ajax) {
872 wp_send_json_success( [
873 'message' => __( 'Webhook is not active', 'retainful' )
874 ] );
875 }
876 return;
877 }
878 }
879
880 // Priority 1: Get email from checkout field (billing_email)
881 $email = '';
882 if ( function_exists( 'WC' ) && isset( WC()->checkout ) && Util::isMethodExists( WC()->checkout, 'get_value' ) ) {
883 $email = WC()->checkout->get_value( 'billing_email' );
884 }
885 // Check if this is an AJAX request for tracking user data
886 $ajax_action = wp_doing_ajax() ? Input::get( 'action', '' ) : '';
887 if ( empty( $email ) && $ajax_action === 'rnoc_track_user_data' ) {
888 $email = Input::get( 'billing_email', '' );
889 $is_buyer_accept_sms = Input::get( 'sms_consent', '' );
890 $is_buyer_accept_marketing = Input::get( 'allow_gdpr', '' );
891 $billing_email = Input::get( 'billing_email', '' );
892 $billing_phone = Input::get( 'billing_phone', '' );
893 $billing_first_name = Input::get( 'billing_first_name', '' );
894 $billing_last_name = Input::get( 'billing_last_name', '' );
895 $session = Settings::getStorage();
896 if ( ! empty( $billing_first_name ) ) {
897 $session->set( 'rtfl_customer_first_name', $billing_first_name );
898 }
899 if ( ! empty( $billing_last_name ) ) {
900 $session->set( 'rtfl_customer_last_name', $billing_last_name );
901 }
902 if ( ! empty( $billing_phone ) ) {
903 $session->set( 'rtfl_customer_phone', $billing_phone );
904 }
905 if ( ! empty( $billing_email ) && is_email( $billing_email ) ) {
906 $session->set( 'rtfl_customer_email', $billing_email );
907 }
908
909 if(!empty($is_buyer_accept_sms)){
910 $session->set( 'is_buyer_accepting_sms_marketing', $is_buyer_accept_sms );
911 }
912 if(!empty($is_buyer_accept_marketing)){
913 $session->set( 'is_buyer_accepting_marketing', $is_buyer_accept_marketing );
914 }
915 if ( ! empty( $email ) && is_email( $email ) ) {
916 Customer::setCustomerEmail( $email );
917 }
918
919 }
920
921 // Priority 2: Get email from customer billing email
922 if ( empty( $email ) && function_exists( 'WC' ) && isset( WC()->customer ) && Util::isMethodExists( WC()->customer, 'get_billing_email' ) ) {
923 $email = WC()->customer->get_billing_email();
924 }
925
926 // Priority 3: Get email from logged-in user
927 if ( empty( $email ) && function_exists( 'wp_get_current_user' ) ) {
928 $current_user = wp_get_current_user();
929 $email = ! empty( $current_user->user_email ) ? $current_user->user_email : '';
930 }
931
932 // Priority 4: Get email from cookie data (for non-logged-in users)
933 if ( empty( $email ) ) {
934 $email = Common::getGuestEmail();
935 }
936 $is_valid_cart_to_track = AbandonedCart::isValidCartToTrack();
937 // Explicit parentheses to fix operator-precedence bug.
938 // Previously && bound tighter than ||, so needToTrackCart()
939 // was ALWAYS evaluated on non-AJAX paths (every page load).
940 $is_ajax_track = ( ! empty( $email ) && wp_doing_ajax() && $ajax_action === 'rnoc_track_user_data' );
941 $is_need_to_track = $is_ajax_track || (!empty($email) && !wp_doing_ajax());
942 if ( ! $is_valid_cart_to_track || ! $is_need_to_track ) {
943 if($is_ajax) {
944 wp_send_json_success( [
945 'message' => __( 'Cart is not valid to track', 'retainful' )
946 ] );
947 }
948 return;
949 }
950
951 if ( empty( $email ) || ! is_email( $email ) ) {
952 if($is_ajax) {
953 wp_send_json_success( [
954 'message' => __( 'Email is not valid', 'retainful' )
955 ] );
956 }
957 return;
958 }
959
960 $track_id = AbandonedCart::getAbandonedCartCookieId( $email );
961 $header_id = AbandonedCart::getSyncHeaderId( $email );
962
963 if ( empty( $track_id ) || empty( $header_id ) ) {
964 if($is_ajax) {
965 wp_send_json_success( [
966 'message' => __( 'Track ID or header ID is not valid', 'retainful' )
967 ] );
968 }
969 return;
970 }
971
972 $cart_token = AbandonedCart::getCartToken();
973 AbandonedCart::setAbandonedCartData( $track_id, $email, $cart_token );
974 AbandonedCart::setHeaderData( $header_id);
975
976 // Dispatch cart sync (handles both direct and scheduled sync) via dispatch function
977 $action_args = [
978 'args' => [
979 'track_id' => $track_id,
980 'cart_token' => $cart_token,
981 'email' => $email,
982 'header_id' => $header_id
983 ]
984 ];
985
986 $callback = function ( $args ) {
987 /*if ( is_array( $args ) ) {
988 $track_id = $args['track_id'] ?? '';
989 $cart_token = $args['cart_token'] ?? '';
990 $email = $args['email'] ?? '';
991 $header_id = $args['header_id'] ?? '';
992 $time = $args['schedule_time'] ?? time();
993 self::abandonedCartTrackWebhookTrigger( $track_id, $cart_token, $email, $header_id, $time );
994 }*/
995 self::abandonedCartTrackWebhookTrigger( $args['args'] ?? [] );
996 };
997
998 $sync_result = Dispatch::dispatchAction(
999 'retainful_checkout_started_sync',
1000 $action_args,
1001 $callback,
1002 60, // 10 * 10 seconds delay
1003 false
1004 );
1005
1006 if($is_ajax) {
1007 wp_send_json_success( [
1008 'message' => __( 'Cart sync completed successfully', 'retainful' )
1009 ] );
1010 }
1011 return;
1012 /*if (
1013 wp_doing_ajax()
1014 && $ajax_action === 'rnoc_track_user_data'
1015 && ! empty( $sync_result['success'] )
1016 ) {
1017 wp_send_json_success(
1018 [
1019 'message' => 'User data updated successfully.',
1020 ]
1021 );
1022 }
1023
1024 return $sync_result['success'];*/
1025 } catch ( \Exception $e ) {
1026 Settings::logMessage( $e->getMessage(), 'cart_sync_error' );
1027 if($is_ajax) {
1028 wp_send_json_success( [
1029 'message' => $e->getMessage()
1030 ] );
1031 }
1032 return;
1033 } finally {
1034 // Always release the re-entrancy guard so a subsequent
1035 // hook in the same request can run syncCart again.
1036 // Note: this finally does not run when wp_send_json_*
1037 // exits the script, but in those cases the request is
1038 // over so the static flag does not matter.
1039 self::$sync_cart_running = false;
1040 }
1041 }
1042 public static function abandonedCartTrackWebhookTrigger( $args ) {
1043 $track_id = $args['track_id'] ?? '';
1044 $email = $args['email'] ?? '';
1045 $cart_token = $args['cart_token'] ?? '';
1046 $header_id = $args['header_id'] ?? '';
1047 if ( empty( $track_id ) || empty( $email ) || empty( $cart_token ) || ! class_exists( 'WC_Data_Store' ) || ! function_exists( 'wc_get_webhook' ) ) {
1048 return;
1049 }
1050
1051 $topics = [ 'checkout.retainful_started' ];
1052 $payload = [
1053 'track_id' => $track_id,
1054 'email' => $email,
1055 'cart_token' => $cart_token,
1056 'header_id' => $header_id
1057 ];
1058
1059 // Custom topic matcher for checkout webhooks
1060 $topic_matcher = function ( $webhook_topic, $expected_topic ) {
1061 return strpos( $webhook_topic, 'checkout' ) !== false && $webhook_topic === "checkout.retainful_started";
1062 };
1063
1064 // Custom validator to check resource and webhook ID
1065 $webhook_validator = function ( $webhook, $webhook_id ) {
1066 $resource = Util::isMethodExists( $webhook, 'get_resource' ) ? $webhook->get_resource() : '';
1067
1068 return Settings::hasWebhookId( $webhook_id ) && $resource === 'checkout';
1069 };
1070
1071 Dispatch::deliverWebhook( $topics, $payload, $topic_matcher, $webhook_validator );
1072 }
1073
1074 public static function OrderWebhookDeliver( $order_id ) {
1075 if ( empty( $order_id ) || ! function_exists( 'wc_get_webhook' ) ) {
1076 return;
1077 }
1078
1079 $topics = [ 'order.updated' ];
1080 $payload = [
1081 'order_id' => (int) $order_id,
1082 ];
1083
1084 // Custom topic matcher for order webhooks
1085 $topic_matcher = function ( $webhook_topic, $expected_topic ) {
1086 return strpos( $webhook_topic, 'order' ) !== false && $webhook_topic === "order.updated";
1087 };
1088
1089 Dispatch::deliverWebhook( $topics, $payload, $topic_matcher );
1090 }
1091
1092 /**
1093 * Deliver order status change webhook.
1094 *
1095 * @param int $order_id The order ID.
1096 * @param string $order_status The order status.
1097 *
1098 * @return void
1099 */
1100 public static function deliverOrderStatusWebHook( $args ): void {
1101 $order_id = $args['order_id'] ?? '';
1102 $order_status = $args['order_status'] ?? '';
1103 if ( empty( $order_id ) || empty( $order_status ) ) {
1104 return;
1105 }
1106
1107 $order = Order::getOrder( $order_id );
1108 // SYNTAX FIX: Changed |! to || !
1109 if ( empty( $order ) || ! $order instanceof \WC_Order ) {
1110 return;
1111 }
1112 $topic = '';
1113 switch ( $order_status ) {
1114 case 'cancelled':
1115 $topic = 'retainful_order.retainful_cancelled';
1116 break;
1117 case 'fulfilled':
1118 $topic = 'retainful_order.retainful_fulfilled';
1119 break;
1120 case 'paid':
1121 $topic = 'retainful_order.retainful_paid';
1122 break;
1123 case 'refunded':
1124 $topic = 'retainful_order.retainful_refunded';
1125 break;
1126 case 'placed':
1127 $topic = 'retainful_order.retainful_placed';
1128 break;
1129 }
1130 // Custom validator to check webhook ID
1131 $webhook_validator = function ( $webhook, $webhook_id ) {
1132 return Settings::hasWebhookId( $webhook_id );
1133 };
1134
1135 Dispatch::deliverWebhook( $topic, $args, null, $webhook_validator );
1136 }
1137
1138 }