PluginProbe
WANotifier for Forms and Actions / 3.1.1
WANotifier for Forms and Actions v3.1.1
3.1.1 3.1.0 3.0.4 2.7.10 2.7.11 2.7.12 2.7.13 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 3.0.0 3.0.1 3.0.2 3.0.3 trunk 0.1.0 0.1.1 1.0.0 1.0.1 1.0.2 All 67 releases
← All changes | includes/classes/integrations/class-notifier-woocommerce.php +1040 -275 2.7.53.1.1 View file →
@@ -1,5 +1,9 @@
1 1 <?php
2 +if ( ! defined( 'ABSPATH' ) ) {
3 + exit;
4 +}
5 +
2 6 /**
3 7 * Woocommerce notifications
4 8 *
5 9 * @package Wa_Notifier
@@ -14,8 +18,10 @@
14 18 add_filter( 'notifier_notification_merge_tags', array( __CLASS__, 'woocommerce_merge_tags') );
15 19 add_filter( 'notifier_notification_recipient_fields', array( __CLASS__, 'woocommerce_recipient_fields') );
16 20 add_action( 'woocommerce_review_order_before_submit', array( __CLASS__, 'add_checkout_optin_fields') );
17 21 add_action( 'woocommerce_checkout_update_order_meta', array( __CLASS__, 'notifier_save_checkout_field'));
22 + add_action( 'woocommerce_admin_order_data_after_billing_address', array( __CLASS__, 'render_order_optin_field' ) );
23 + add_action( 'woocommerce_process_shop_order_meta', array( __CLASS__, 'save_admin_order_meta' ), 10, 2 );
18 24
19 25 add_action( 'wp_ajax_notifier_save_cart_abandonment_data', array(__CLASS__, 'notifier_save_cart_abandonment_data'));
20 26 add_action( 'wp_ajax_nopriv_notifier_save_cart_abandonment_data', array(__CLASS__, 'notifier_save_cart_abandonment_data'));
21 27 add_action( 'woocommerce_cart_updated', array( __CLASS__, 'notifier_update_cart_data_on_update'), 999);
@@ -23,16 +29,44 @@
23 29
24 30 add_action( 'woocommerce_new_order', array( __CLASS__ , 'notifier_save_session_on_new_order' ), 10, 1);
25 31
26 32 add_action( 'wp', array(__CLASS__, 'retrive_cart_data_from_url'));
33 +
34 + // v3: Action Scheduler handler for async schema push when new meta keys are discovered.
35 + add_action( 'notifier_push_updated_schema', array( __CLASS__, 'handle_push_updated_schema' ), 10, 1 );
36 +
37 + // v3: contact sync hooks — priority 100 so WooCommerce and other plugins have
38 + // already written all billing/user meta before we read it.
39 + add_action( 'user_register', array( __CLASS__, 'on_user_register' ), 100 );
40 + add_action( 'profile_update', array( __CLASS__, 'on_user_update' ), 100, 2 );
41 + add_action( 'woocommerce_checkout_update_order_meta', array( __CLASS__, 'on_checkout_contact_sync' ), 100 );
42 +
43 + // v3: Action Scheduler handler for batch contact sync.
44 + add_action( 'notifier_wc_batch_contact_sync', array( __CLASS__, 'process_batch_contact_sync_page' ), 10, 3 );
27 45 }
28 46
29 47 /**
48 + * Merge tag group types relevant to a WooCommerce order context.
49 + * Used by both get_woo_notification_triggers() and build_wc_order_payload().
50 + */
51 + public static function get_order_merge_tag_types() {
52 + return array( 'WordPress', 'WooCommerce', 'WooCommerce Order', 'WooCommerce Customer', 'WooCommerce Order Custom Meta', 'WooCommerce Customer User Meta' );
53 + }
54 +
55 + /**
56 + * Recipient field group types relevant to a WooCommerce order context.
57 + * Used by both get_woo_notification_triggers() and build_wc_order_payload().
58 + */
59 + public static function get_order_recipient_types() {
60 + return array( 'WooCommerce', 'WooCommerce Order Custom Meta', 'WooCommerce Customer User Meta' );
61 + }
62 +
63 + /**
30 64 * Add Woocommerce notification triggers
31 65 */
32 66 public static function get_woo_notification_triggers() {
33 - $merge_tag_types = array('WooCommerce', 'WooCommerce Order', 'WooCommerce Customer', 'WooCommerce Order Custom Meta', 'WooCommerce Customer User Meta', 'WooCommerce Cart Abandonment');
34 - $recipient_types = array('WooCommerce', 'WooCommerce Order Custom Meta', 'WooCommerce Customer User Meta');
67 + $merge_tag_types = self::get_order_merge_tag_types();
68 + $recipient_types = self::get_order_recipient_types();
35 69 $triggers = array (
36 70 array(
37 71 'id' => 'woo_order_new',
38 72 'label' => 'New order is placed',
@@ -39,9 +73,9 @@
39 73 'description' => 'Trigger notification when a new order is placed.',
40 74 'merge_tags' => Notifier_Notification_Merge_Tags::get_merge_tags($merge_tag_types),
41 75 'recipient_fields' => Notifier_Notification_Merge_Tags::get_recipient_fields($recipient_types),
42 76 'action' => array(
43 - 'hook' => 'woocommerce_checkout_order_processed',
77 + 'hook' => 'woocommerce_new_order',
44 78 'callback' => function ( $order_id ){
45 79 if (!self::maybe_send_notification($order_id)) {
46 80 return;
47 81 }
@@ -60,9 +94,9 @@
60 94 'description' => 'Trigger notification when a new order is placed with Cash on Delivery payment method selected.',
61 95 'merge_tags' => Notifier_Notification_Merge_Tags::get_merge_tags($merge_tag_types),
62 96 'recipient_fields' => Notifier_Notification_Merge_Tags::get_recipient_fields($recipient_types),
63 97 'action' => array(
64 - 'hook' => 'woocommerce_checkout_order_processed',
98 + 'hook' => 'woocommerce_new_order',
65 99 'callback' => function ( $order_id ){
66 100 if (!self::maybe_send_notification($order_id)) {
67 101 return;
68 102 }
@@ -81,9 +115,9 @@
81 115 )
82 116 ),
83 117 array(
84 118 'id' => 'woocommerce_cart_abandonment',
85 - 'label' => 'Cart is abandoned (New)',
119 + 'label' => 'Cart is abandoned',
86 120 'description' => 'Trigger notification when cart is abandoned as per your setup in the Settings > WooCommerce tab.',
87 121 'merge_tags' => Notifier_Notification_Merge_Tags::get_merge_tags(array('WooCommerce', 'WooCommerce Cart Abandonment')),
88 122 'recipient_fields' => Notifier_Notification_Merge_Tags::get_recipient_fields(array('WooCommerce Cart Abandonment')),
89 123 'action' => array(
@@ -146,9 +180,10 @@
146 180 'id' => 'woo_store_url',
147 181 'label' => 'Shop page url',
148 182 'preview_value' => 'https://example.com/shop/',
149 183 'return_type' => 'text',
150 - 'value' => function ($args) {
184 + 'source' => 'static',
185 + 'value' => function ( $bulk_data ) {
151 186 return (string) get_permalink( wc_get_page_id( 'shop' ) );
152 187 }
153 188 ),
154 189 array(
@@ -155,253 +190,399 @@
155 190 'id' => 'woo_my_account_url',
156 191 'label' => 'My Account page url',
157 192 'preview_value' => 'https://example.com/my-account/',
158 193 'return_type' => 'text',
159 - 'value' => function ($args) {
160 - return get_permalink( wc_get_page_id( 'myaccount' ) );
194 + 'source' => 'static',
195 + 'value' => function ( $bulk_data ) {
196 + return (string) get_permalink( wc_get_page_id( 'myaccount' ) );
161 197 }
162 198 ),
163 199 );
164 200
165 - $woo_fields = array ();
166 - $woo_fields['WooCommerce Order'] = array ( // Woocommerce order fields
167 - 'id' => array(
168 - 'preview' => '123',
169 - 'label' => 'Order ID'
201 + // WooCommerce Order fields — source/path maps directly to get_base_data() keys.
202 + // Fields with custom formatting use a 'value' closure receiving $bulk_data.
203 + $woo_fields = array();
204 + $woo_fields['WooCommerce Order'] = array(
205 + 'id' => array(
206 + 'preview' => '123',
207 + 'label' => 'Order ID',
208 + 'source' => 'base',
209 + 'path' => 'id',
170 210 ),
171 - 'subtotal_to_display' => array(
172 - 'preview' => '$50.00',
173 - 'label' => 'Order subtotal'
211 + 'subtotal_to_display' => array(
212 + 'preview' => '$50.00',
213 + 'label' => 'Order subtotal',
214 + 'source' => 'computed',
215 + 'value' => function ( $bulk_data ) {
216 + return html_entity_decode( $bulk_data['order']->get_subtotal_to_display() );
217 + },
174 218 ),
175 - 'coupon_codes' => array(
176 - 'preview' => 'OFF50',
177 - 'label' => 'Order coupon codes',
178 - 'value' => function ($order, $field_function) {
179 - return implode(', ', $order->$field_function());
180 - }
219 + 'coupon_codes' => array(
220 + 'preview' => 'OFF50',
221 + 'label' => 'Order coupon codes',
222 + 'source' => 'coupons',
223 + 'value' => function ( $bulk_data ) {
224 + $codes = array();
225 + foreach ( $bulk_data['coupons'] as $item ) {
226 + $codes[] = $item->get_code();
227 + }
228 + return implode( ', ', $codes );
229 + },
181 230 ),
182 - 'discount_to_display' => array(
183 - 'preview' => '$5.00',
184 - 'label' => 'Order discount amount'
231 + 'discount_to_display' => array(
232 + 'preview' => '$5.00',
233 + 'label' => 'Order discount amount',
234 + 'source' => 'computed',
235 + 'value' => function ( $bulk_data ) {
236 + return html_entity_decode( $bulk_data['order']->get_discount_to_display() );
237 + },
185 238 ),
186 - 'shipping_method' => array(
187 - 'preview' => 'Flat rate',
188 - 'label' => 'Order shipping method'
239 + 'shipping_method' => array(
240 + 'preview' => 'Flat rate',
241 + 'label' => 'Order shipping method',
242 + 'source' => 'shipping',
243 + 'value' => function ( $bulk_data ) {
244 + $methods = array();
245 + foreach ( $bulk_data['shipping'] as $item ) {
246 + $methods[] = $item->get_method_title();
247 + }
248 + return implode( ', ', $methods );
249 + },
189 250 ),
190 - 'shipping_total' => array(
191 - 'preview' => '$2.50',
192 - 'label' => 'Order shipping amount',
193 - 'value' => function ($order, $field_function) {
194 - return wc_price( $order->$field_function(), array( 'currency' => $order->get_currency() ) );
195 - }
251 + 'shipping_total' => array(
252 + 'preview' => '$2.50',
253 + 'label' => 'Order shipping amount',
254 + 'source' => 'base',
255 + 'path' => 'shipping_total',
256 + 'value' => function ( $bulk_data ) {
257 + $currency = $bulk_data['base']['currency'];
258 + return html_entity_decode( wc_price( $bulk_data['base']['shipping_total'], array( 'currency' => $currency ) ) );
259 + },
196 260 ),
197 - 'total_tax' => array(
198 - 'preview' => '$5.00',
199 - 'label' => 'Order tax amount',
200 - 'value' => function ($order, $field_function) {
201 - return wc_price( $order->$field_function(), array( 'currency' => $order->get_currency() ) );
202 - }
261 + 'total_tax' => array(
262 + 'preview' => '$5.00',
263 + 'label' => 'Order tax amount',
264 + 'source' => 'base',
265 + 'path' => 'total_tax',
266 + 'value' => function ( $bulk_data ) {
267 + $currency = $bulk_data['base']['currency'];
268 + return html_entity_decode( wc_price( $bulk_data['base']['total_tax'], array( 'currency' => $currency ) ) );
269 + },
203 270 ),
204 271 'formatted_order_total' => array(
205 - 'preview' => '$50.00',
206 - 'label' => 'Order total amount'
272 + 'preview' => '$50.00',
273 + 'label' => 'Order total amount',
274 + 'source' => 'computed',
275 + 'value' => function ( $bulk_data ) {
276 + return html_entity_decode( $bulk_data['order']->get_formatted_order_total() );
277 + },
207 278 ),
208 - 'payment_method_title' => array(
209 - 'preview' => 'Cash on delivery',
210 - 'label' => 'Order payment method'
279 + 'payment_method_title' => array(
280 + 'preview' => 'Cash on delivery',
281 + 'label' => 'Order payment method',
282 + 'source' => 'base',
283 + 'path' => 'payment_method_title',
211 284 ),
212 - 'checkout_payment_url' => array(
213 - 'preview' => 'https://example.com/checkout/order-received/123/?key=wc_order_abcdef',
214 - 'label' => 'Order checkout payment URL'
285 + 'checkout_payment_url' => array(
286 + 'preview' => 'https://example.com/checkout/order-received/123/?key=wc_order_abcdef',
287 + 'label' => 'Order checkout payment URL',
288 + 'source' => 'computed',
289 + 'value' => function ( $bulk_data ) {
290 + return $bulk_data['order']->get_checkout_payment_url();
291 + },
215 292 ),
216 - 'checkout_order_received_url' => array(
217 - 'preview' => 'https://example.com/checkout/order-received/123/?key=wc_order_abcdef',
218 - 'label' => 'Order thank you page URL'
293 + 'checkout_order_received_url' => array(
294 + 'preview' => 'https://example.com/checkout/order-received/123/?key=wc_order_abcdef',
295 + 'label' => 'Order thank you page URL',
296 + 'source' => 'computed',
297 + 'value' => function ( $bulk_data ) {
298 + return $bulk_data['order']->get_checkout_order_received_url();
299 + },
219 300 ),
220 - 'view_order_url' => array(
221 - 'preview' => 'https://example.com/my-account/view-order/123/',
222 - 'label' => 'Order view URL'
301 + 'view_order_url' => array(
302 + 'preview' => 'https://example.com/my-account/view-order/123/',
303 + 'label' => 'Order view URL',
304 + 'source' => 'computed',
305 + 'value' => function ( $bulk_data ) {
306 + return $bulk_data['order']->get_view_order_url();
307 + },
223 308 ),
224 - 'status' => array(
225 - 'preview' => 'Processing',
226 - 'label' => 'Order status'
309 + 'status' => array(
310 + 'preview' => 'Processing',
311 + 'label' => 'Order status',
312 + 'source' => 'base',
313 + 'path' => 'status',
227 314 ),
228 315 'first_product_image' => array(
229 - 'preview' => '',
230 - 'label' => 'Order product image',
231 - 'return_type' => 'image',
232 - 'value' => function ($order, $field_function) {
233 - $image_id = false;
234 - $image_url = '';
235 - foreach($order->get_items() as $item){
236 - $first_product_id = $item->get_product_id();
237 - $product = wc_get_product( $first_product_id );
238 - $image_id = $product->get_image_id();
239 - if($image_id){
240 - break;
316 + 'preview' => '',
317 + 'label' => 'Order first item featured image',
318 + 'return_type' => 'image',
319 + 'source' => 'items',
320 + 'value' => function ( $bulk_data ) {
321 + foreach ( $bulk_data['items'] as $item ) {
322 + $product = $item->get_product();
323 + if ( $product ) {
324 + $image_id = $product->get_image_id();
325 + return $image_id ? wp_get_attachment_url( $image_id ) : wc_placeholder_img_src();
241 326 }
327 + break;
242 328 }
243 -
244 - if($image_id){
245 - $image_url = wp_get_attachment_url( $product->get_image_id() );
329 + return wc_placeholder_img_src();
330 + },
331 + ),
332 + 'products_list' => array(
333 + 'preview' => '',
334 + 'label' => 'Order products list',
335 + 'return_type' => 'text',
336 + 'source' => 'items',
337 + 'value' => function ( $bulk_data ) {
338 + $parts = array();
339 + $currency = $bulk_data['base']['currency'];
340 + foreach ( $bulk_data['items'] as $item ) {
341 + $parts[] = sanitize_textarea_field(
342 + $item->get_name() . ' x ' . $item->get_quantity() . ' (' . wc_price( $item->get_total(), array( 'currency' => $currency ) ) . ')'
343 + );
246 344 }
247 - else{
248 - $image_url = wc_placeholder_img_src();
345 + return implode( ', ', $parts );
346 + },
347 + ),
348 + 'order_first_item_name' => array(
349 + 'preview' => '',
350 + 'label' => 'Order first item name',
351 + 'return_type' => 'text',
352 + 'source' => 'items',
353 + 'value' => function ( $bulk_data ) {
354 + foreach ( $bulk_data['items'] as $item ) {
355 + return $item->get_name();
249 356 }
250 - return $image_url;
251 - }
357 + return '';
358 + },
252 359 ),
253 - 'products_list' => array(
254 - 'preview' => '',
255 - 'label' => 'Order products list',
256 - 'return_type' => 'text',
257 - 'value' => function ($order, $field_function) {
258 - $order_item_data = array();
259 - foreach ( $order->get_items() as $item ) {
260 - $order_item = $item->get_name().' x '.$item->get_quantity().' ('.wc_price( $item->get_total() ).')';
261 - $order_item_data[] = sanitize_textarea_field($order_item);
360 + 'order_first_item_price' => array(
361 + 'preview' => '',
362 + 'label' => 'Order first item price',
363 + 'return_type' => 'text',
364 + 'source' => 'items',
365 + 'value' => function ( $bulk_data ) {
366 + $currency = $bulk_data['base']['currency'];
367 + foreach ( $bulk_data['items'] as $item ) {
368 + $product = $item->get_product();
369 + if ( $product ) {
370 + return html_entity_decode( wc_price( $product->get_price(), array( 'currency' => $currency ) ) );
371 + }
372 + break;
262 373 }
263 -
264 - return implode(', ',$order_item_data);
265 - }
374 + return '';
375 + },
266 376 ),
377 + 'order_first_item_total' => array(
378 + 'preview' => '',
379 + 'label' => 'Order first item total',
380 + 'return_type' => 'text',
381 + 'source' => 'items',
382 + 'value' => function ( $bulk_data ) {
383 + $currency = $bulk_data['base']['currency'];
384 + foreach ( $bulk_data['items'] as $item ) {
385 + return html_entity_decode( wc_price( $item->get_total(), array( 'currency' => $currency ) ) );
386 + }
387 + return '';
388 + },
389 + ),
267 390 );
268 391
269 - $woo_fields['WooCommerce Customer'] = array (
270 - 'billing_first_name' => array('preview' => 'John'),
271 - 'billing_last_name' => array('preview' => 'Doe'),
272 - 'billing_company' => array('preview' => 'ABC Inc'),
273 - 'billing_address_1' => array('preview' => '123, XYZ Street'),
274 - 'billing_address_2' => array('preview' => 'XYZ Avenue'),
275 - 'billing_city' => array('preview' => 'New York'),
276 - 'billing_postcode' => array('preview' => '12345'),
277 - 'billing_state' => array(
278 - 'preview' => 'NY',
279 - 'value' => function ($order, $field_function) {
280 - $country_function = str_replace('state', 'country', $field_function);
281 - $state_code = $order->$field_function();
282 - $country_code = $order->$country_function();
283 - return WC()->countries->states[$country_code][$state_code];
284 - }
392 + $woo_fields['WooCommerce Customer'] = array(
393 + 'billing_first_name' => array(
394 + 'preview' => 'John',
395 + 'source' => 'base',
396 + 'path' => 'billing.first_name',
285 397 ),
286 - 'billing_country' => array(
287 - 'preview' => 'US',
288 - 'value' => function ($order, $field_function) {
289 - return WC()->countries->countries[$order->$field_function()];
290 - }
398 + 'billing_last_name' => array(
399 + 'preview' => 'Doe',
400 + 'source' => 'base',
401 + 'path' => 'billing.last_name',
291 402 ),
292 - 'billing_email' => array('preview' => 'john@example.com'),
293 - 'billing_phone' => array('preview' => '987 654 321'),
294 - 'formatted_billing_address' => array(
295 - 'preview' => '',
296 - 'label' => 'Complete billing address',
297 - 'value' => function ($order, $field_function) {
298 - return str_replace('<br/>', ', ', $order->$field_function());
299 - }
403 + 'billing_company' => array(
404 + 'preview' => 'ABC Inc',
405 + 'source' => 'base',
406 + 'path' => 'billing.company',
300 407 ),
301 - 'shipping_first_name' => array('preview' => 'John'),
302 - 'shipping_last_name' => array('preview' => 'Doe'),
303 - 'shipping_company' => array('preview' => 'ABC Inc'),
304 - 'shipping_address_1' => array('preview' => '123, XYZ Street'),
305 - 'shipping_address_2' => array('preview' => 'XYZ Avenue'),
306 - 'shipping_city' => array('preview' => 'New York'),
307 - 'shipping_postcode' => array('preview' => '12345'),
308 - 'shipping_state' => array(
309 - 'preview' => 'NY',
310 - 'value' => function ($order, $field_function) {
311 - $country_function = str_replace('state', 'country', $field_function);
312 - $state_code = $order->$field_function();
313 - $country_code = $order->$country_function();
314 - return WC()->countries->states[$country_code][$state_code];
315 - }
408 + 'billing_address_1' => array(
409 + 'preview' => '123, XYZ Street',
410 + 'source' => 'base',
411 + 'path' => 'billing.address_1',
316 412 ),
317 - 'shipping_country' => array(
318 - 'preview' => 'US',
319 - 'value' => '',
320 - 'value' => function ($order, $field_function) {
321 - return WC()->countries->countries[$order->$field_function()];
322 - }
413 + 'billing_address_2' => array(
414 + 'preview' => 'XYZ Avenue',
415 + 'source' => 'base',
416 + 'path' => 'billing.address_2',
323 417 ),
324 - 'shipping_phone' => array('preview' => '987 654 321'),
325 - 'formatted_shipping_address' => array(
326 - 'preview' => '',
327 - 'label' => 'Complete shipping address',
328 - 'value' => function ($order, $field_function) {
329 - return str_replace('<br/>', ', ', $order->$field_function());
330 - }
331 - )
418 + 'billing_city' => array(
419 + 'preview' => 'New York',
420 + 'source' => 'base',
421 + 'path' => 'billing.city',
422 + ),
423 + 'billing_postcode' => array(
424 + 'preview' => '12345',
425 + 'source' => 'base',
426 + 'path' => 'billing.postcode',
427 + ),
428 + 'billing_state' => array(
429 + 'preview' => 'NY',
430 + 'source' => 'base',
431 + 'path' => 'billing.state',
432 + 'value' => function ( $bulk_data ) {
433 + $country = $bulk_data['base']['billing']['country'];
434 + $state = $bulk_data['base']['billing']['state'];
435 + return WC()->countries->states[ $country ][ $state ] ?? $state;
436 + },
437 + ),
438 + 'billing_country' => array(
439 + 'preview' => 'US',
440 + 'source' => 'base',
441 + 'path' => 'billing.country',
442 + 'value' => function ( $bulk_data ) {
443 + $code = $bulk_data['base']['billing']['country'];
444 + return WC()->countries->countries[ $code ] ?? $code;
445 + },
446 + ),
447 + 'billing_email' => array(
448 + 'preview' => 'john@example.com',
449 + 'source' => 'base',
450 + 'path' => 'billing.email',
451 + ),
452 + 'billing_phone' => array(
453 + 'preview' => '987 654 321',
454 + 'source' => 'base',
455 + 'path' => 'billing.phone',
456 + 'value' => function ( $bulk_data ) {
457 + $phone = $bulk_data['base']['billing']['phone'] ?? '';
458 + $country = $bulk_data['base']['billing']['country'] ?? '';
459 + return html_entity_decode( sanitize_text_field( self::get_formatted_phone_number( $phone, $country ) ) );
460 + },
461 + ),
462 + 'formatted_billing_address' => array(
463 + 'preview' => '',
464 + 'label' => 'Complete billing address',
465 + 'source' => 'computed',
466 + 'value' => function ( $bulk_data ) {
467 + return str_replace( '<br/>', ', ', $bulk_data['order']->get_formatted_billing_address() );
468 + },
469 + ),
470 + 'shipping_first_name' => array(
471 + 'preview' => 'John',
472 + 'source' => 'base',
473 + 'path' => 'shipping.first_name',
474 + ),
475 + 'shipping_last_name' => array(
476 + 'preview' => 'Doe',
477 + 'source' => 'base',
478 + 'path' => 'shipping.last_name',
479 + ),
480 + 'shipping_company' => array(
481 + 'preview' => 'ABC Inc',
482 + 'source' => 'base',
483 + 'path' => 'shipping.company',
484 + ),
485 + 'shipping_address_1' => array(
486 + 'preview' => '123, XYZ Street',
487 + 'source' => 'base',
488 + 'path' => 'shipping.address_1',
489 + ),
490 + 'shipping_address_2' => array(
491 + 'preview' => 'XYZ Avenue',
492 + 'source' => 'base',
493 + 'path' => 'shipping.address_2',
494 + ),
495 + 'shipping_city' => array(
496 + 'preview' => 'New York',
497 + 'source' => 'base',
498 + 'path' => 'shipping.city',
499 + ),
500 + 'shipping_postcode' => array(
501 + 'preview' => '12345',
502 + 'source' => 'base',
503 + 'path' => 'shipping.postcode',
504 + ),
505 + 'shipping_state' => array(
506 + 'preview' => 'NY',
507 + 'source' => 'base',
508 + 'path' => 'shipping.state',
509 + 'value' => function ( $bulk_data ) {
510 + $country = $bulk_data['base']['shipping']['country'];
511 + $state = $bulk_data['base']['shipping']['state'];
512 + return WC()->countries->states[ $country ][ $state ] ?? $state;
513 + },
514 + ),
515 + 'shipping_country' => array(
516 + 'preview' => 'US',
517 + 'source' => 'base',
518 + 'path' => 'shipping.country',
519 + 'value' => function ( $bulk_data ) {
520 + $code = $bulk_data['base']['shipping']['country'];
521 + return WC()->countries->countries[ $code ] ?? $code;
522 + },
523 + ),
524 + 'shipping_phone' => array(
525 + 'preview' => '987 654 321',
526 + 'source' => 'base',
527 + 'path' => 'shipping.phone',
528 + 'value' => function ( $bulk_data ) {
529 + $phone = $bulk_data['base']['shipping']['phone'] ?? '';
530 + $country = $bulk_data['base']['shipping']['country'] ?? '';
531 + return html_entity_decode( sanitize_text_field( self::get_formatted_phone_number( $phone, $country ) ) );
532 + },
533 + ),
534 + 'formatted_shipping_address' => array(
535 + 'preview' => '',
536 + 'label' => 'Complete shipping address',
537 + 'source' => 'computed',
538 + 'value' => function ( $bulk_data ) {
539 + return str_replace( '<br/>', ', ', $bulk_data['order']->get_formatted_shipping_address() );
540 + },
541 + ),
332 542 );
333 543
334 - foreach ($woo_fields as $woo_field_key => $woo_field) {
335 - foreach ($woo_field as $field => $field_data) {
336 - if (isset($field_data['label'])) {
337 - $label = $field_data['label'];
338 - } else {
339 - $label = ucfirst( str_replace('_', ' ', $field) );
340 - }
341 -
342 - $merge_tags[$woo_field_key][] = array(
343 - 'id' => 'woo_order_' . $field,
344 - 'label' => $label,
345 - 'preview_value' => isset($field_data['preview']) ? $field_data['preview'] : '',
346 - 'return_type' => isset($field_data['return_type']) ? $field_data['return_type'] : 'text',
347 - 'value' => function ($args) use ($field, $field_data) {
348 - $order = wc_get_order( $args['object_id'] );
349 - $field_function = 'get_' . $field;
350 - if (isset($field_data['value'])) {
351 - $value = $field_data['value']($order, $field_function);
352 - } else {
353 - $value = $order->$field_function();
354 - }
355 - return html_entity_decode(sanitize_text_field($value));
356 - }
544 + foreach ( $woo_fields as $woo_field_key => $woo_field ) {
545 + foreach ( $woo_field as $field => $field_data ) {
546 + $label = isset( $field_data['label'] ) ? $field_data['label'] : ucfirst( str_replace( '_', ' ', $field ) );
547 + $merge_tags[ $woo_field_key ][] = array(
548 + 'id' => 'woo_order_' . $field,
549 + 'label' => $label,
550 + 'preview_value' => isset( $field_data['preview'] ) ? $field_data['preview'] : '',
551 + 'return_type' => isset( $field_data['return_type'] ) ? $field_data['return_type'] : 'text',
552 + 'source' => isset( $field_data['source'] ) ? $field_data['source'] : 'base',
553 + 'path' => isset( $field_data['path'] ) ? $field_data['path'] : null,
554 + 'value' => isset( $field_data['value'] ) ? $field_data['value'] : null,
357 555 );
358 556 }
359 557 }
360 558
361 - // Order meta keys
362 - $order_meta_keys = Notifier_Notification_Merge_Tags::get_custom_meta_keys('shop_order');
363 - if(!empty($order_meta_keys)){
364 - foreach($order_meta_keys as $order_meta_key){
559 + // Order meta keys — read from persistent option (populated on connect/sync).
560 + $order_meta_keys = Notifier_Notification_Merge_Tags::get_custom_meta_keys( 'shop_order' );
561 + if ( ! empty( $order_meta_keys ) ) {
562 + foreach ( $order_meta_keys as $order_meta_key ) {
365 563 $merge_tags['WooCommerce Order Custom Meta'][] = array(
366 - 'id' => 'woo_order_meta_' . $order_meta_key,
367 - 'label' => $order_meta_key,
564 + 'id' => 'woo_order_meta_' . $order_meta_key,
565 + 'label' => $order_meta_key,
368 566 'preview_value' => '123',
369 - 'return_type' => 'all',
370 - 'value' => function ($args) use ($order_meta_key) {
371 - $order = wc_get_order( $args['object_id'] );
372 - $value = $order->get_meta( $order_meta_key );
373 - if(is_array($value) || is_object($value)){
374 - $value = json_encode($value);
375 - }
376 - return (string) $value;
377 - }
567 + 'return_type' => 'all',
568 + 'source' => 'meta',
569 + 'path' => $order_meta_key,
378 570 );
379 571 }
380 572 }
381 573
382 - // WooCommerce customer user meta
574 + // WooCommerce customer user meta — read from persistent option.
383 575 $user_meta_keys = Notifier_Notification_Merge_Tags::get_user_meta_keys();
384 - if(!empty($user_meta_keys)){
385 - foreach($user_meta_keys as $user_meta_key){
576 + if ( ! empty( $user_meta_keys ) ) {
577 + foreach ( $user_meta_keys as $user_meta_key ) {
386 578 $merge_tags['WooCommerce Customer User Meta'][] = array(
387 - 'id' => 'woo_customer_meta_' . $user_meta_key,
388 - 'label' => $user_meta_key,
579 + 'id' => 'woo_customer_meta_' . $user_meta_key,
580 + 'label' => $user_meta_key,
389 581 'preview_value' => '123',
390 - 'return_type' => 'all',
391 - 'value' => function ($args) use ($user_meta_key) {
392 - $order = wc_get_order( $args['object_id'] );
393 - $user_id = $order->get_user_id();
394 - if(0 == $user_id){
395 - return '';
396 - }
397 -
398 - $value = get_user_meta($user_id, $user_meta_key, true);
399 - if(is_array($value) || is_object($value)){
400 - $value = json_encode($value);
401 - }
402 - return (string) $value;
403 - }
582 + 'return_type' => 'all',
583 + 'source' => 'user_meta',
584 + 'path' => $user_meta_key,
404 585 );
405 586 }
406 587 }
407 588
@@ -407,9 +588,9 @@
407 588
408 589 $merge_tags['WooCommerce Cart Abandonment'] = array(
409 590 array(
410 591 'id' => 'wca_cart_products_list',
411 - 'label' => 'Adandoned cart products list',
592 + 'label' => 'Abandoned cart products list',
412 593 'preview_value' => '',
413 594 'return_type' => 'text',
414 595 'value' => function ($cart_details) {
415 596 return $cart_details ? self::get_comma_separated_products(maybe_unserialize($cart_details['cart_data'])) : '';
@@ -414,20 +595,20 @@
414 595 'value' => function ($cart_details) {
415 596 return $cart_details ? self::get_comma_separated_products(maybe_unserialize($cart_details['cart_data'])) : '';
416 597 }
417 598 ),
599 + array(
600 + 'id' => 'wca_cart_total',
601 + 'label' => 'Abandoned cart total',
602 + 'preview_value' => '',
603 + 'return_type' => 'text',
604 + 'value' => function ($cart_details) {
605 + return $cart_details['cart_total'] ?? '';
606 + }
607 + ),
418 608 array(
419 - 'id' => 'wca_cart_total',
420 - 'label' => 'Adandoned cart total',
421 - 'preview_value' => '',
422 - 'return_type' => 'text',
423 - 'value' => function ($cart_details) {
424 - return $cart_entry['cart_total'] ?? '';
425 - }
426 - ),
427 - array(
428 609 'id' => 'wca_cart_datetime',
429 - 'label' => 'Adandoned cart datetime',
610 + 'label' => 'Abandoned cart datetime',
430 611 'preview_value' => '',
431 612 'return_type' => 'text',
432 613 'value' => function ($cart_details) {
433 614 return $cart_details ? $cart_details['created_time'] : '';
@@ -463,8 +644,21 @@
463 644 return wc_get_cart_url();
464 645 }
465 646 ),
466 647 array(
648 + 'id' => 'wca_cart_session_key',
649 + 'label' => 'Abandoned cart recovery key',
650 + 'preview_value' => '',
651 + 'return_type' => 'text',
652 + 'value' => function ($cart_details) {
653 + if ($cart_details && isset($cart_details['session_key'])) {
654 + $session_key = sanitize_key($cart_details['session_key']);
655 + return $session_key;
656 + }
657 + return '';
658 + }
659 + ),
660 + array(
467 661 'id' => 'wca_cart_customer_email',
468 662 'label' => 'Billing email',
469 663 'preview_value' => '',
470 664 'return_type' => 'text',
@@ -478,11 +672,11 @@
478 672 'label' => 'Coupon details',
479 673 'preview_value' => '',
480 674 'return_type' => 'text',
481 675 'value' => function ($cart_details) {
482 - $coupon_data = $data ? maybe_unserialize($cart_details['coupon_content']) : [];
483 - return $coupon_data ? implode(', ', $coupon_data) : '';
484 - }
676 + $coupon_data = $cart_details ? maybe_unserialize($cart_details['coupon_content'] ?? '') : [];
677 + return $coupon_data ? implode(', ', $coupon_data) : '';
678 + }
485 679 ),
486 680 );
487 681
488 682 $wca_other_fields = array(
@@ -685,77 +879,60 @@
685 879
686 880 /*
687 881 * Add recipient fields for WooCommerce
688 882 */
689 - public static function woocommerce_recipient_fields($recipient_fields){
883 + public static function woocommerce_recipient_fields( $recipient_fields ) {
690 884 $recipient_fields['WooCommerce'] = array(
691 885 array(
692 - 'id' => 'woo_order_billing_phone',
693 - 'label' => 'Billing phone number',
694 - 'value' => function ($args) {
695 - $order = wc_get_order( $args['object_id'] );
696 - $phone_number = $order->get_billing_phone();
697 - $country_code = $order->get_billing_country();
698 - $phone_number = self::get_formatted_phone_number($phone_number, $country_code);
699 - return html_entity_decode(sanitize_text_field($phone_number));
700 - }
886 + 'id' => 'woo_order_billing_phone',
887 + 'label' => 'Billing phone number',
888 + 'source' => 'base',
889 + 'path' => 'billing.phone',
890 + 'value' => function ( $bulk_data ) {
891 + $phone = $bulk_data['base']['billing']['phone'];
892 + $country = $bulk_data['base']['billing']['country'];
893 + return html_entity_decode( sanitize_text_field( self::get_formatted_phone_number( $phone, $country ) ) );
894 + },
701 895 ),
702 896 array(
703 - 'id' => 'woo_order_shipping_phone',
704 - 'label' => 'Shipping phone number',
705 - 'value' => function ($args) {
706 - $order = wc_get_order( $args['object_id'] );
707 - $phone_number = $order->get_shipping_phone();
708 - $country_code = $order->get_shipping_country();
709 - $phone_number = self::get_formatted_phone_number($phone_number, $country_code);
710 - return html_entity_decode(sanitize_text_field($phone_number));
711 - }
712 - )
897 + 'id' => 'woo_order_shipping_phone',
898 + 'label' => 'Shipping phone number',
899 + 'source' => 'base',
900 + 'path' => 'shipping.phone',
901 + 'value' => function ( $bulk_data ) {
902 + $phone = $bulk_data['base']['shipping']['phone'];
903 + $country = $bulk_data['base']['shipping']['country'];
904 + return html_entity_decode( sanitize_text_field( self::get_formatted_phone_number( $phone, $country ) ) );
905 + },
906 + ),
713 907 );
714 908
715 - // Order meta keys
716 - $order_meta_keys = Notifier_Notification_Merge_Tags::get_custom_meta_keys('shop_order');
717 - if(!empty($order_meta_keys)){
718 - foreach($order_meta_keys as $order_meta_key){
909 + // Order meta keys — read from persistent option.
910 + $order_meta_keys = Notifier_Notification_Merge_Tags::get_custom_meta_keys( 'shop_order' );
911 + if ( ! empty( $order_meta_keys ) ) {
912 + foreach ( $order_meta_keys as $order_meta_key ) {
719 913 $recipient_fields['WooCommerce Order Custom Meta'][] = array(
720 - 'id' => 'woo_order_meta_' . $order_meta_key,
721 - 'label' => $order_meta_key,
914 + 'id' => 'woo_order_meta_' . $order_meta_key,
915 + 'label' => $order_meta_key,
722 916 'preview_value' => '123',
723 - 'return_type' => 'text',
724 - 'value' => function ($args) use ($order_meta_key) {
725 - $order = wc_get_order( $args['object_id'] );
726 - $value = $order->get_meta( $order_meta_key );
727 - if(is_array($value) || is_object($value)){
728 - $value = json_encode($value);
729 - }
730 - return (string) $value;
731 - }
917 + 'return_type' => 'text',
918 + 'source' => 'meta',
919 + 'path' => $order_meta_key,
732 920 );
733 921 }
734 922 }
735 923
736 - // WooCommerce customer user meta
924 + // WooCommerce customer user meta — read from persistent option.
737 925 $user_meta_keys = Notifier_Notification_Merge_Tags::get_user_meta_keys();
738 - if(!empty($user_meta_keys)){
739 - foreach($user_meta_keys as $user_meta_key){
926 + if ( ! empty( $user_meta_keys ) ) {
927 + foreach ( $user_meta_keys as $user_meta_key ) {
740 928 $recipient_fields['WooCommerce Customer User Meta'][] = array(
741 - 'id' => 'woo_customer_meta_' . $user_meta_key,
742 - 'label' => $user_meta_key,
929 + 'id' => 'woo_customer_meta_' . $user_meta_key,
930 + 'label' => $user_meta_key,
743 931 'preview_value' => '123',
744 - 'return_type' => 'text',
745 - 'value' => function ($args) use ($user_meta_key) {
746 - $order = wc_get_order( $args['object_id'] );
747 - $user_id = $order->get_user_id();
748 - if(0 == $user_id){
749 - return '';
750 - }
751 -
752 - $value = get_user_meta($user_id, $user_meta_key, true);
753 - if(is_array($value) || is_object($value)){
754 - $value = json_encode($value);
755 - }
756 - return (string) $value;
757 - }
932 + 'return_type' => 'text',
933 + 'source' => 'user_meta',
934 + 'path' => $user_meta_key,
758 935 );
759 936 }
760 937 }
761 938
@@ -781,8 +958,12 @@
781 958 /**
782 959 * Get phone number with country extension code
783 960 */
784 961 public static function get_formatted_phone_number( $phone_number, $country_code ) {
962 + // If already starts with + and matches international format, return as-is
963 + if ( strpos($phone_number, '+') === 0 && notifier_validate_phone_number($phone_number) ) {
964 + return $phone_number;
965 + }
785 966 $phone_number = notifier_sanitize_phone_number( $phone_number );
786 967 $phone_number = ltrim($phone_number, '0');
787 968 if ( in_array( $country_code, array( 'US', 'CA' ), true ) ) {
788 969 $calling_code = '+1';
@@ -819,9 +1000,9 @@
819 1000 /**
820 1001 * Hook to save the custom field data
821 1002 */
822 1003 public static function notifier_save_checkout_field($order_id) {
823 - $opt_in = sanitize_text_field($_POST[ NOTIFIER_PREFIX . 'whatsapp_opt_in' ]);
1004 + $opt_in = isset( $_POST[ NOTIFIER_PREFIX . 'whatsapp_opt_in' ] ) ? sanitize_text_field( wp_unslash( $_POST[ NOTIFIER_PREFIX . 'whatsapp_opt_in' ] ) ) : '';
824 1005 if ( !empty($opt_in) ) {
825 1006 $order = wc_get_order( $order_id );
826 1007 $order->update_meta_data( NOTIFIER_PREFIX . 'whatsapp_opt_in', $opt_in );
827 1008 $order->save();
@@ -827,8 +1008,63 @@
827 1008 $order->save();
828 1009 }
829 1010 }
830 1011
1012 + /**
1013 + * Render opt-in checkbox after billing address on order edit page.
1014 + * Hook passes a WC_Order object (works with both HPOS and legacy).
1015 + */
1016 + public static function render_order_optin_field( $order ) {
1017 + if ( 'yes' !== get_option( NOTIFIER_PREFIX . 'enable_opt_in_checkbox_checkout' ) ) {
1018 + return;
1019 + }
1020 +
1021 + if ( ! is_a( $order, 'WC_Order' ) ) {
1022 + $order = wc_get_order( $order->ID );
1023 + }
1024 + if ( ! $order ) {
1025 + return;
1026 + }
1027 +
1028 + $checkbox_text = get_option( NOTIFIER_PREFIX . 'checkout_opt_in_checkbox_text' );
1029 + if ( empty( $checkbox_text ) ) {
1030 + $checkbox_text = 'Receive updates on WhatsApp';
1031 + }
1032 +
1033 + $opt_in = $order->get_meta( NOTIFIER_PREFIX . 'whatsapp_opt_in' );
1034 +
1035 + woocommerce_wp_checkbox( array(
1036 + 'id' => NOTIFIER_PREFIX . 'whatsapp_opt_in',
1037 + 'label' => '<strong>' . __( 'WANotifier', 'notifier' ) . '</strong>',
1038 + 'description' => esc_html( $checkbox_text ),
1039 + 'value' => $opt_in,
1040 + 'cbvalue' => '1',
1041 + 'wrapper_class' => 'form-field-wide',
1042 + ) );
1043 + }
1044 +
1045 + /**
1046 + * Hook to save the custom field data on order page
1047 + */
1048 + public static function save_admin_order_meta( $post_id, $post ) {
1049 + if ( ! isset( $_POST['woocommerce_meta_nonce'] ) || ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_POST['woocommerce_meta_nonce'] ) ), 'woocommerce_save_data' ) ) {
1050 + return;
1051 + }
1052 +
1053 + if ( ! current_user_can( 'edit_shop_orders' ) ) {
1054 + return;
1055 + }
1056 +
1057 + $order = wc_get_order( $post_id );
1058 + if ( ! $order ) {
1059 + return;
1060 + }
1061 +
1062 + $opt_in = isset( $_POST[ NOTIFIER_PREFIX . 'whatsapp_opt_in' ] ) ? '1' : '';
1063 + $order->update_meta_data( NOTIFIER_PREFIX . 'whatsapp_opt_in', $opt_in );
1064 + $order->save();
1065 + }
1066 +
831 1067 /**
832 1068 * Check if the notification should be sent based on global and user opt-in settings.
833 1069 */
834 1070 public static function maybe_send_notification( $order_id ) {
@@ -929,11 +1165,30 @@
929 1165 if ($existing_entry && $existing_entry->triggered) {
930 1166 wp_send_json(array('error' => true, 'message' => 'Cart abandonment already processed.' ));
931 1167 }
932 1168
933 - // Determine created_time
934 - $created_time = $existing_entry ? $existing_entry->created_time : current_time('mysql');
1169 + // Fetch all active session_keys for this phone number
1170 + $existing_session_keys = $wpdb->get_col(
1171 + $wpdb->prepare(
1172 + "SELECT session_key FROM $table_name WHERE phone_number = %s AND triggered = 0 AND order_id = 0",
1173 + $phone_number
1174 + )
1175 + );
935 1176
1177 + // Delete all other sessions except the current one
1178 + if (!empty($existing_session_keys)) {
1179 + foreach ($existing_session_keys as $old_session_key) {
1180 + if ($session_key !== $old_session_key) {
1181 + $wpdb->query(
1182 + $wpdb->prepare(
1183 + "DELETE FROM $table_name WHERE session_key = %s",
1184 + $old_session_key
1185 + )
1186 + );
1187 + }
1188 + }
1189 + }
1190 +
936 1191 // Update or insert cart and customer data into the table
937 1192 if ($existing_entry) {
938 1193 // Update the existing entry
939 1194 $wpdb->update(
@@ -1160,9 +1415,520 @@
1160 1415 }
1161 1416 }
1162 1417 }
1163 1418
1419 + // ========================================
1420 + // v3 PAYLOAD BUILDER
1421 + // ========================================
1422 +
1164 1423 /**
1424 + * Build the full WooCommerce order payload for v3 trigger dispatch.
1425 + *
1426 + * Fetches all order data in bulk (one call per data type), then maps
1427 + * each defined merge tag and recipient field using source/path or value closure.
1428 + * Meta keys discovered here that are new are persisted and trigger an async schema push.
1429 + *
1430 + * @param int $order_id WooCommerce order ID.
1431 + * @param string $trigger_id Trigger ID (e.g. 'woo_order_new').
1432 + * @return array Payload with merge_tags_data and recipient_fields.
1433 + */
1434 + public static function build_wc_order_payload( $order_id, $trigger_id ) {
1435 + $order = wc_get_order( $order_id );
1436 + if ( ! $order ) {
1437 + return array( 'merge_tags_data' => array(), 'recipient_fields' => array() );
1438 + }
1439 +
1440 + $customer_id = $order->get_customer_id();
1441 +
1442 + // Single bulk fetch — all data in ~6 calls total.
1443 + $bulk_data = array(
1444 + 'order' => $order,
1445 + 'base' => $order->get_base_data(),
1446 + 'items' => $order->get_items( 'line_item' ),
1447 + 'shipping' => $order->get_items( 'shipping' ),
1448 + 'coupons' => $order->get_items( 'coupon' ),
1449 + 'fees' => $order->get_items( 'fee' ),
1450 + 'meta' => $order->get_meta_data(),
1451 + 'user_meta' => $customer_id ? get_user_meta( $customer_id ) : array(),
1452 + );
1453 +
1454 + // Index meta by key for fast lookups.
1455 + $meta_by_key = array();
1456 + foreach ( $bulk_data['meta'] as $meta_item ) {
1457 + if ( ! empty( $meta_item->key ) ) {
1458 + $meta_by_key[ $meta_item->key ] = $meta_item->value;
1459 + }
1460 + }
1461 +
1462 + // Index user meta (get_user_meta returns arrays of values per key).
1463 + $user_meta_by_key = array();
1464 + foreach ( $bulk_data['user_meta'] as $key => $values ) {
1465 + $user_meta_by_key[ $key ] = is_array( $values ) ? $values[0] : $values;
1466 + }
1467 +
1468 + $merge_tags_data = array();
1469 + $recipient_fields = array();
1470 +
1471 + $all_merge_tags = Notifier_Notification_Merge_Tags::get_merge_tags( self::get_order_merge_tag_types() );
1472 + foreach ( $all_merge_tags as $group => $tags ) {
1473 + foreach ( $tags as $tag ) {
1474 + if ( empty( $tag['id'] ) ) {
1475 + continue;
1476 + }
1477 + if ( 0 === strpos( $tag['id'], 'woo_order_meta_' ) || 0 === strpos( $tag['id'], 'woo_customer_meta_' ) ) {
1478 + continue;
1479 + }
1480 + $value = self::resolve_field_from_bulk( $tag, $bulk_data, $meta_by_key, $user_meta_by_key );
1481 + if ( null !== $value ) {
1482 + $merge_tags_data[ $tag['id'] ] = html_entity_decode( sanitize_text_field( (string) $value ) );
1483 + }
1484 + }
1485 + }
1486 +
1487 + $all_recipient_fields = Notifier_Notification_Merge_Tags::get_recipient_fields( self::get_order_recipient_types() );
1488 + foreach ( $all_recipient_fields as $group => $fields ) {
1489 + foreach ( $fields as $field ) {
1490 + if ( empty( $field['id'] ) ) {
1491 + continue;
1492 + }
1493 + if ( 0 === strpos( $field['id'], 'woo_order_meta_' ) || 0 === strpos( $field['id'], 'woo_customer_meta_' ) ) {
1494 + continue;
1495 + }
1496 + $value = self::resolve_field_from_bulk( $field, $bulk_data, $meta_by_key, $user_meta_by_key );
1497 + if ( null !== $value ) {
1498 + $recipient_fields[ $field['id'] ] = html_entity_decode( sanitize_text_field( (string) $value ) );
1499 + }
1500 + }
1501 + }
1502 +
1503 + foreach ( $meta_by_key as $key => $value ) {
1504 + $tag_id = 'woo_order_meta_' . $key;
1505 + $merge_tags_data[ $tag_id ] = is_array( $value ) || is_object( $value ) ? json_encode( $value ) : (string) $value;
1506 + $recipient_fields[ $tag_id ] = $merge_tags_data[ $tag_id ];
1507 + }
1508 +
1509 + foreach ( $user_meta_by_key as $key => $value ) {
1510 + $tag_id = 'woo_customer_meta_' . $key;
1511 + $merge_tags_data[ $tag_id ] = is_array( $value ) || is_object( $value ) ? json_encode( $value ) : (string) $value;
1512 + $recipient_fields[ $tag_id ] = $merge_tags_data[ $tag_id ];
1513 + }
1514 +
1515 + // Live diff: detect new meta keys and persist + schedule async schema push.
1516 + self::maybe_update_known_meta_keys( 'shop_order', array_keys( $meta_by_key ) );
1517 + if ( $customer_id ) {
1518 + self::maybe_update_known_meta_keys( '_user', array_keys( $user_meta_by_key ) );
1519 + }
1520 +
1521 + return array(
1522 + 'merge_tags_data' => $merge_tags_data,
1523 + 'recipient_fields' => $recipient_fields,
1524 + );
1525 + }
1526 +
1527 + /**
1528 + * Resolve a single merge tag or recipient field value from pre-fetched bulk data.
1529 + *
1530 + * @param array $field_def Field definition with source, path, and/or value closure.
1531 + * @param array $bulk_data Pre-fetched bulk data array.
1532 + * @param array $meta_by_key Order meta indexed by key.
1533 + * @param array $user_meta_by_key User meta indexed by key.
1534 + * @return string|null Resolved value, or null if not applicable.
1535 + */
1536 + private static function resolve_field_from_bulk( $field_def, $bulk_data, $meta_by_key, $user_meta_by_key ) {
1537 + if ( isset( $field_def['value'] ) && is_callable( $field_def['value'] ) ) {
1538 + return call_user_func( $field_def['value'], $bulk_data );
1539 + }
1540 +
1541 + $source = isset( $field_def['source'] ) ? $field_def['source'] : null;
1542 + $path = isset( $field_def['path'] ) ? $field_def['path'] : null;
1543 +
1544 + if ( ! $source || ! $path ) {
1545 + return null;
1546 + }
1547 +
1548 + if ( 'base' === $source ) {
1549 + $parts = explode( '.', $path );
1550 + $data = $bulk_data['base'];
1551 + foreach ( $parts as $part ) {
1552 + if ( ! isset( $data[ $part ] ) ) {
1553 + return '';
1554 + }
1555 + $data = $data[ $part ];
1556 + }
1557 + return (string) $data;
1558 + }
1559 +
1560 + if ( 'meta' === $source ) {
1561 + $value = $meta_by_key[ $path ] ?? '';
1562 + return is_array( $value ) || is_object( $value ) ? json_encode( $value ) : (string) $value;
1563 + }
1564 +
1565 + if ( 'user_meta' === $source ) {
1566 + $value = $user_meta_by_key[ $path ] ?? '';
1567 + return is_array( $value ) || is_object( $value ) ? json_encode( $value ) : (string) $value;
1568 + }
1569 +
1570 + return null;
1571 + }
1572 +
1573 + /**
1574 + * Compare discovered meta keys against stored known keys.
1575 + * If new keys found, update the persistent option and schedule an async schema push.
1576 + *
1577 + * @param string $type Post type key (e.g. 'shop_order') or '_user' for user meta.
1578 + * @param array $live_keys Keys found in the current object's meta.
1579 + */
1580 + private static function maybe_update_known_meta_keys( $type, $live_keys ) {
1581 + if ( '_user' === $type ) {
1582 + $known = get_option( 'notifier_user_meta_keys', array() );
1583 + $new_keys = array_diff( $live_keys, $known );
1584 + if ( ! empty( $new_keys ) ) {
1585 + update_option( 'notifier_user_meta_keys', array_unique( array_merge( $known, $new_keys ) ), false );
1586 + as_enqueue_async_action( 'notifier_push_updated_schema', array( 'slug' => 'woocommerce' ), 'notifier' );
1587 + }
1588 + return;
1589 + }
1590 +
1591 + $all_known = get_option( 'notifier_custom_meta_keys', array() );
1592 + $known = isset( $all_known[ $type ] ) ? $all_known[ $type ] : array();
1593 + $new_keys = array_diff( $live_keys, $known );
1594 + if ( ! empty( $new_keys ) ) {
1595 + $all_known[ $type ] = array_unique( array_merge( $known, $new_keys ) );
1596 + update_option( 'notifier_custom_meta_keys', $all_known, false );
1597 + as_enqueue_async_action( 'notifier_push_updated_schema', array( 'slug' => 'woocommerce' ), 'notifier' );
1598 + }
1599 + }
1600 +
1601 + /**
1602 + * Action Scheduler handler: push updated trigger schema to SaaS after new meta keys discovered.
1603 + *
1604 + * @param string $slug Integration slug.
1605 + */
1606 + public static function handle_push_updated_schema( $slug ) {
1607 + if ( ! Notifier_Connection::is_connected( $slug ) ) {
1608 + return;
1609 + }
1610 + $trigger_schema = Notifier_Connection::build_trigger_schema( $slug );
1611 + Notifier_Connection::send_saas_request( $slug, 'sync-triggers', array( 'triggers' => $trigger_schema ) );
1612 + }
1613 +
1614 + // ========================================
1615 + // v3 CONTACT SYNC
1616 + // ========================================
1617 +
1618 + /**
1619 + * Sync a WP user as a contact to SaaS when they register.
1620 + *
1621 + * @param int $user_id
1622 + */
1623 + public static function on_user_register( $user_id ) {
1624 + self::sync_customer_contact( $user_id, 'woo_customer_created' );
1625 + }
1626 +
1627 + /**
1628 + * Sync a WP user as a contact to SaaS when their profile is updated.
1629 + *
1630 + * @param int $user_id
1631 + * @param WP_User $old_user_data
1632 + */
1633 + public static function on_user_update( $user_id, $old_user_data ) {
1634 + self::sync_customer_contact( $user_id, 'woo_customer_updated' );
1635 + }
1636 +
1637 + /**
1638 + * Sync contact from checkout billing data (covers guest checkouts).
1639 + *
1640 + * Only runs when continuous sync is enabled in the connection config.
1641 + *
1642 + * @param int $order_id
1643 + */
1644 + public static function on_checkout_contact_sync( $order_id ) {
1645 + $conn = Notifier_Connection::get_connection( 'woocommerce' );
1646 + if ( empty( $conn ) || empty( $conn['continuous_sync'] ) ) {
1647 + return;
1648 + }
1649 +
1650 + $order = wc_get_order( $order_id );
1651 + if ( ! $order ) {
1652 + return;
1653 + }
1654 +
1655 + $phone = $order->get_billing_phone();
1656 + if ( empty( $phone ) ) {
1657 + return;
1658 + }
1659 +
1660 + $contact = array(
1661 + 'billing' => array(
1662 + 'phone' => $phone,
1663 + 'email' => $order->get_billing_email(),
1664 + 'first_name' => $order->get_billing_first_name(),
1665 + 'last_name' => $order->get_billing_last_name(),
1666 + 'company' => $order->get_billing_company(),
1667 + 'address_1' => $order->get_billing_address_1(),
1668 + 'address_2' => $order->get_billing_address_2(),
1669 + 'city' => $order->get_billing_city(),
1670 + 'state' => $order->get_billing_state(),
1671 + 'postcode' => $order->get_billing_postcode(),
1672 + 'country' => $order->get_billing_country(),
1673 + ),
1674 + );
1675 +
1676 + self::push_contacts_to_saas( array( $contact ), array(), 'woo_customer_created' );
1677 + }
1678 +
1679 + /**
1680 + * Sync a WordPress/WooCommerce user as a contact to the SaaS.
1681 + *
1682 + * Only runs when continuous sync is enabled in the connection config.
1683 + *
1684 + * @param int $user_id
1685 + * @param string $event_key Trigger slug: 'woo_customer_created' or 'woo_customer_updated'.
1686 + */
1687 + private static function sync_customer_contact( $user_id, $event_key ) {
1688 + $conn = Notifier_Connection::get_connection( 'woocommerce' );
1689 + if ( empty( $conn ) ) {
1690 + return;
1691 + }
1692 +
1693 + // Respect the continuous sync toggle.
1694 + if ( empty( $conn['continuous_sync'] ) ) {
1695 + return;
1696 + }
1697 +
1698 + $phone = get_user_meta( $user_id, 'billing_phone', true );
1699 + if ( empty( $phone ) ) {
1700 + return; // Can't sync without a phone number.
1701 + }
1702 +
1703 + $contact = self::build_customer_contact_data( $user_id );
1704 + self::push_contacts_to_saas( array( $contact ), array(), $event_key );
1705 + }
1706 +
1707 + /**
1708 + * Push contacts to the SaaS via the unified /{webhook_key}/event endpoint.
1709 + *
1710 + * @param array $contacts Array of contact data arrays in WC customer format.
1711 + * @param array $meta Optional metadata (total_contacts, remaining_contacts).
1712 + * @param string $event_key Trigger slug identifying the event: 'woo_customer_created',
1713 + * 'woo_customer_updated', or 'woo_customer_batch_sync'.
1714 + */
1715 + private static function push_contacts_to_saas( $contacts, $meta = array(), $event_key = 'woo_customer_created' ) {
1716 + $conn = Notifier_Connection::get_connection( 'woocommerce' );
1717 + if ( empty( $conn ) || empty( $conn['webhook_key'] ) || empty( $conn['saas_url'] ) ) {
1718 + return;
1719 + }
1720 +
1721 + $url = trailingslashit( $conn['saas_url'] ) . NOTIFIER_APP_API_PATH . '/integrations/woocommerce/' . $conn['webhook_key'] . '/event';
1722 + $body = array_merge( array(
1723 + 'event_key' => $event_key,
1724 + 'contacts' => $contacts,
1725 + ), $meta );
1726 +
1727 +
1728 + $response = wp_remote_post( $url, array(
1729 + 'headers' => array(
1730 + 'Content-Type' => 'application/json',
1731 + 'X-Auth-Key' => $conn['auth_key'] ?? '',
1732 + ),
1733 + 'body' => wp_json_encode( $body ),
1734 + 'timeout' => 30,
1735 + 'sslverify' => true,
1736 + ) );
1737 +
1738 + if ( is_wp_error( $response ) ) {
1739 + error_log( 'WANotifier: push_contacts_to_saas failed: ' . $response->get_error_message() );
1740 + }
1741 + }
1742 +
1743 + /**
1744 + * REST handler: SaaS pushes config updates (e.g. continuous_sync toggle).
1745 + *
1746 + * Stores the config values in the plugin's WooCommerce connection option so
1747 + * the real-time sync hooks can read them without calling the SaaS.
1748 + *
1749 + * @param WP_REST_Request $request
1750 + * @return WP_REST_Response
1751 + */
1752 + public static function handle_rest_update_config( $request ) {
1753 + $data = $request->get_json_params();
1754 +
1755 + $conn = Notifier_Connection::get_connection( 'woocommerce' );
1756 + if ( empty( $conn ) ) {
1757 + return new WP_REST_Response( array( 'error' => true, 'message' => 'Not connected.' ), 400 );
1758 + }
1759 +
1760 + if ( array_key_exists( 'continuous_sync', $data ) ) {
1761 + $conn['continuous_sync'] = ! empty( $data['continuous_sync'] );
1762 + Notifier_Connection::save_connection( 'woocommerce', $conn );
1763 + }
1764 +
1765 + return new WP_REST_Response( array( 'error' => false, 'message' => 'Config updated.' ), 200 );
1766 + }
1767 +
1768 + /**
1769 + * REST handler: SaaS requests the plugin to stop a running batch customer sync.
1770 + *
1771 + * Cancels all pending Action Scheduler jobs for the batch sync and sets a
1772 + * transient flag so any currently-running page handler exits early.
1773 + *
1774 + * @param WP_REST_Request $request
1775 + * @return WP_REST_Response
1776 + */
1777 + public static function handle_rest_batch_sync_stop( $request ) {
1778 + // Cancel all queued (not yet started) batch sync pages.
1779 + as_unschedule_all_actions( 'notifier_wc_batch_contact_sync', array(), 'notifier' );
1780 +
1781 + // Set a short-lived flag so any in-progress page handler exits before pushing to SaaS.
1782 + set_transient( 'notifier_wc_batch_sync_stopped', 1, 5 * MINUTE_IN_SECONDS );
1783 +
1784 + return new WP_REST_Response( array(
1785 + 'error' => false,
1786 + 'message' => 'Batch sync stopped.',
1787 + ), 200 );
1788 + }
1789 +
1790 + /**
1791 + * REST handler: SaaS requests the plugin to start a batch customer sync.
1792 + *
1793 + * Schedules an Action Scheduler job on the plugin side that pages through
1794 + * all WooCommerce customers and pushes them to the SaaS /{webhook_key}/event endpoint.
1795 + *
1796 + * @param WP_REST_Request $request
1797 + * @return WP_REST_Response
1798 + */
1799 + public static function handle_rest_batch_sync_start( $request ) {
1800 +
1801 + // Cancel any existing pending batch sync jobs to avoid duplicates.
1802 + as_unschedule_all_actions( 'notifier_wc_batch_contact_sync', array(), 'notifier' );
1803 +
1804 + // Clear any lingering stop flag from a previous stop request.
1805 + delete_transient( 'notifier_wc_batch_sync_stopped' );
1806 +
1807 + // Count total customers with billing phone.
1808 + $total_query = new WP_User_Query( array(
1809 + 'role__in' => array( 'customer', 'subscriber' ),
1810 + 'meta_key' => 'billing_phone',
1811 + 'meta_compare' => '!=',
1812 + 'meta_value' => '',
1813 + 'number' => -1,
1814 + 'fields' => 'ID',
1815 + ) );
1816 + $total = count( $total_query->get_results() );
1817 +
1818 +
1819 + // Schedule first page.
1820 + as_enqueue_async_action(
1821 + 'notifier_wc_batch_contact_sync',
1822 + array( 'page' => 1, 'per_page' => 50, 'total' => $total ),
1823 + 'notifier'
1824 + );
1825 +
1826 +
1827 + return new WP_REST_Response( array(
1828 + 'error' => false,
1829 + 'message' => 'Batch sync started.',
1830 + 'total' => $total,
1831 + ), 200 );
1832 + }
1833 +
1834 + /**
1835 + * Action Scheduler handler: process one page of the batch customer sync.
1836 + *
1837 + * Runs in the plugin. Fetches a page of WC customers, builds contact data,
1838 + * and pushes the batch to the SaaS /{webhook_key}/event endpoint. Schedules the next
1839 + * page if more customers remain.
1840 + *
1841 + * @param int $page Current page number.
1842 + * @param int $per_page Number of customers per page.
1843 + * @param int $total Total customers to sync.
1844 + */
1845 + public static function process_batch_contact_sync_page( $page, $per_page, $total ) {
1846 + // Abort immediately if a stop was requested while this job was queued.
1847 + if ( get_transient( 'notifier_wc_batch_sync_stopped' ) ) {
1848 + return;
1849 + }
1850 +
1851 + $page = (int) $page ?: 1;
1852 + $per_page = (int) $per_page ?: 50;
1853 + $total = (int) $total ?: 0;
1854 +
1855 +
1856 + $user_query = new WP_User_Query( array(
1857 + 'role__in' => array( 'customer', 'subscriber' ),
1858 + 'meta_key' => 'billing_phone',
1859 + 'meta_compare' => '!=',
1860 + 'meta_value' => '',
1861 + 'number' => $per_page,
1862 + 'paged' => $page,
1863 + 'orderby' => 'ID',
1864 + 'order' => 'ASC',
1865 + ) );
1866 +
1867 + $users = $user_query->get_results();
1868 +
1869 + if ( empty( $users ) ) {
1870 + return;
1871 + }
1872 +
1873 +
1874 + $contacts = array();
1875 + foreach ( $users as $user ) {
1876 + $contacts[] = self::build_customer_contact_data( $user->ID );
1877 + }
1878 +
1879 + $processed_so_far = $page * $per_page;
1880 + $remaining = max( 0, $total - $processed_so_far );
1881 +
1882 +
1883 + self::push_contacts_to_saas( $contacts, array(
1884 + 'total_contacts' => $total,
1885 + 'remaining_contacts' => $remaining,
1886 + ), 'woo_customer_batch_sync' );
1887 +
1888 + if ( $remaining > 0 ) {
1889 + as_enqueue_async_action(
1890 + 'notifier_wc_batch_contact_sync',
1891 + array( 'page' => $page + 1, 'per_page' => $per_page, 'total' => $total ),
1892 + 'notifier'
1893 + );
1894 + }
1895 + }
1896 +
1897 + /**
1898 + * Build contact data array for a WP user in WC customer format.
1899 + *
1900 + * @param int $user_id
1901 + * @return array
1902 + */
1903 + private static function build_customer_contact_data( $user_id ) {
1904 + $user = get_userdata( $user_id );
1905 + $billing_phone = get_user_meta( $user_id, 'billing_phone', true );
1906 + $billing_country = get_user_meta( $user_id, 'billing_country', true );
1907 +
1908 + return array(
1909 + 'id' => $user_id,
1910 + 'email' => $user ? $user->user_email : '',
1911 + 'first_name' => get_user_meta( $user_id, 'first_name', true ) ?: get_user_meta( $user_id, 'billing_first_name', true ),
1912 + 'last_name' => get_user_meta( $user_id, 'last_name', true ) ?: get_user_meta( $user_id, 'billing_last_name', true ),
1913 + 'username' => $user ? $user->user_login : '',
1914 + 'billing' => array(
1915 + 'phone' => self::get_formatted_phone_number( $billing_phone, $billing_country ),
1916 + 'email' => get_user_meta( $user_id, 'billing_email', true ),
1917 + 'first_name' => get_user_meta( $user_id, 'billing_first_name', true ),
1918 + 'last_name' => get_user_meta( $user_id, 'billing_last_name', true ),
1919 + 'company' => get_user_meta( $user_id, 'billing_company', true ),
1920 + 'address_1' => get_user_meta( $user_id, 'billing_address_1', true ),
1921 + 'address_2' => get_user_meta( $user_id, 'billing_address_2', true ),
1922 + 'city' => get_user_meta( $user_id, 'billing_city', true ),
1923 + 'state' => get_user_meta( $user_id, 'billing_state', true ),
1924 + 'postcode' => get_user_meta( $user_id, 'billing_postcode', true ),
1925 + 'country' => $billing_country,
1926 + ),
1927 + );
1928 + }
1929 +
1930 + /**
1165 1931 * store session id during order creation
1166 1932 */
1167 1933 public static function notifier_save_session_on_new_order($order_id) {
1168 1934 if ('yes' !== get_option('notifier_enable_abandonment_cart_tracking', 'no')) {
@@ -1188,6 +1954,5 @@
1188 1954 );
1189 1955 as_unschedule_all_actions('notifier_check_cart_abandonment', array(sanitize_key($session_key)));
1190 1956 }
1191 1957 }
1192 -
1193 1958 }