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 +994 -323 2.7.6 → 3.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,10 +18,9 @@
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'));
18 - add_action( 'add_meta_boxes', array( __CLASS__, 'register_order_meta_box' ) );
19 - add_action( 'woocommerce_admin_order_data_after_billing_address', array( __CLASS__, 'edit_admin_order_meta' ) );
22 + add_action( 'woocommerce_admin_order_data_after_billing_address', array( __CLASS__, 'render_order_optin_field' ) );
20 23 add_action( 'woocommerce_process_shop_order_meta', array( __CLASS__, 'save_admin_order_meta' ), 10, 2 );
21 24
22 25 add_action( 'wp_ajax_notifier_save_cart_abandonment_data', array(__CLASS__, 'notifier_save_cart_abandonment_data'));
23 26 add_action( 'wp_ajax_nopriv_notifier_save_cart_abandonment_data', array(__CLASS__, 'notifier_save_cart_abandonment_data'));
@@ -26,16 +29,44 @@
26 29
27 30 add_action( 'woocommerce_new_order', array( __CLASS__ , 'notifier_save_session_on_new_order' ), 10, 1);
28 31
29 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 );
30 45 }
31 46
32 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 + /**
33 64 * Add Woocommerce notification triggers
34 65 */
35 66 public static function get_woo_notification_triggers() {
36 - $merge_tag_types = array('WooCommerce', 'WooCommerce Order', 'WooCommerce Customer', 'WooCommerce Order Custom Meta', 'WooCommerce Customer User Meta', 'WooCommerce Cart Abandonment');
37 - $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();
38 69 $triggers = array (
39 70 array(
40 71 'id' => 'woo_order_new',
41 72 'label' => 'New order is placed',
@@ -42,9 +73,9 @@
42 73 'description' => 'Trigger notification when a new order is placed.',
43 74 'merge_tags' => Notifier_Notification_Merge_Tags::get_merge_tags($merge_tag_types),
44 75 'recipient_fields' => Notifier_Notification_Merge_Tags::get_recipient_fields($recipient_types),
45 76 'action' => array(
46 - 'hook' => 'woocommerce_checkout_order_processed',
77 + 'hook' => 'woocommerce_new_order',
47 78 'callback' => function ( $order_id ){
48 79 if (!self::maybe_send_notification($order_id)) {
49 80 return;
50 81 }
@@ -63,9 +94,9 @@
63 94 'description' => 'Trigger notification when a new order is placed with Cash on Delivery payment method selected.',
64 95 'merge_tags' => Notifier_Notification_Merge_Tags::get_merge_tags($merge_tag_types),
65 96 'recipient_fields' => Notifier_Notification_Merge_Tags::get_recipient_fields($recipient_types),
66 97 'action' => array(
67 - 'hook' => 'woocommerce_checkout_order_processed',
98 + 'hook' => 'woocommerce_new_order',
68 99 'callback' => function ( $order_id ){
69 100 if (!self::maybe_send_notification($order_id)) {
70 101 return;
71 102 }
@@ -84,9 +115,9 @@
84 115 )
85 116 ),
86 117 array(
87 118 'id' => 'woocommerce_cart_abandonment',
88 - 'label' => 'Cart is abandoned (New)',
119 + 'label' => 'Cart is abandoned',
89 120 'description' => 'Trigger notification when cart is abandoned as per your setup in the Settings > WooCommerce tab.',
90 121 'merge_tags' => Notifier_Notification_Merge_Tags::get_merge_tags(array('WooCommerce', 'WooCommerce Cart Abandonment')),
91 122 'recipient_fields' => Notifier_Notification_Merge_Tags::get_recipient_fields(array('WooCommerce Cart Abandonment')),
92 123 'action' => array(
@@ -149,9 +180,10 @@
149 180 'id' => 'woo_store_url',
150 181 'label' => 'Shop page url',
151 182 'preview_value' => 'https://example.com/shop/',
152 183 'return_type' => 'text',
153 - 'value' => function ($args) {
184 + 'source' => 'static',
185 + 'value' => function ( $bulk_data ) {
154 186 return (string) get_permalink( wc_get_page_id( 'shop' ) );
155 187 }
156 188 ),
157 189 array(
@@ -158,290 +190,399 @@
158 190 'id' => 'woo_my_account_url',
159 191 'label' => 'My Account page url',
160 192 'preview_value' => 'https://example.com/my-account/',
161 193 'return_type' => 'text',
162 - 'value' => function ($args) {
163 - 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' ) );
164 197 }
165 198 ),
166 199 );
167 200
168 - $woo_fields = array ();
169 - $woo_fields['WooCommerce Order'] = array ( // Woocommerce order fields
170 - 'id' => array(
171 - 'preview' => '123',
172 - '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',
173 210 ),
174 - 'subtotal_to_display' => array(
175 - 'preview' => '$50.00',
176 - '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 + },
177 218 ),
178 - 'coupon_codes' => array(
179 - 'preview' => 'OFF50',
180 - 'label' => 'Order coupon codes',
181 - 'value' => function ($order, $field_function) {
182 - return implode(', ', $order->$field_function());
183 - }
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 + },
184 230 ),
185 - 'discount_to_display' => array(
186 - 'preview' => '$5.00',
187 - '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 + },
188 238 ),
189 - 'shipping_method' => array(
190 - 'preview' => 'Flat rate',
191 - '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 + },
192 250 ),
193 - 'shipping_total' => array(
194 - 'preview' => '$2.50',
195 - 'label' => 'Order shipping amount',
196 - 'value' => function ($order, $field_function) {
197 - return wc_price( $order->$field_function(), array( 'currency' => $order->get_currency() ) );
198 - }
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 + },
199 260 ),
200 - 'total_tax' => array(
201 - 'preview' => '$5.00',
202 - 'label' => 'Order tax amount',
203 - 'value' => function ($order, $field_function) {
204 - return wc_price( $order->$field_function(), array( 'currency' => $order->get_currency() ) );
205 - }
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 + },
206 270 ),
207 271 'formatted_order_total' => array(
208 - 'preview' => '$50.00',
209 - '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 + },
210 278 ),
211 - 'payment_method_title' => array(
212 - 'preview' => 'Cash on delivery',
213 - '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',
214 284 ),
215 - 'checkout_payment_url' => array(
216 - 'preview' => 'https://example.com/checkout/order-received/123/?key=wc_order_abcdef',
217 - '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 + },
218 292 ),
219 - 'checkout_order_received_url' => array(
220 - 'preview' => 'https://example.com/checkout/order-received/123/?key=wc_order_abcdef',
221 - '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 + },
222 300 ),
223 - 'view_order_url' => array(
224 - 'preview' => 'https://example.com/my-account/view-order/123/',
225 - '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 + },
226 308 ),
227 - 'status' => array(
228 - 'preview' => 'Processing',
229 - 'label' => 'Order status'
309 + 'status' => array(
310 + 'preview' => 'Processing',
311 + 'label' => 'Order status',
312 + 'source' => 'base',
313 + 'path' => 'status',
230 314 ),
231 315 'first_product_image' => array(
232 - 'preview' => '',
233 - 'label' => 'Order first item featured image',
234 - 'return_type' => 'image',
235 - 'value' => function ($order, $field_function) {
236 - $image_id = false;
237 - $image_url = '';
238 - foreach($order->get_items() as $item){
239 - $first_product_id = $item->get_product_id();
240 - $product = wc_get_product( $first_product_id );
241 - $image_id = $product->get_image_id();
242 - if($image_id){
243 - 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();
244 326 }
327 + break;
245 328 }
246 -
247 - if($image_id){
248 - $image_url = wp_get_attachment_url( $product->get_image_id() );
249 - }
250 - else{
251 - $image_url = wc_placeholder_img_src();
252 - }
253 - return $image_url;
254 - }
329 + return wc_placeholder_img_src();
330 + },
255 331 ),
256 332 'products_list' => array(
257 - 'preview' => '',
258 - 'label' => 'Order products list',
259 - 'return_type' => 'text',
260 - 'value' => function ($order, $field_function) {
261 - $order_item_data = array();
262 - foreach ( $order->get_items() as $item ) {
263 - $order_item = $item->get_name().' x '.$item->get_quantity().' ('.wc_price( $item->get_total() ).')';
264 - $order_item_data[] = sanitize_textarea_field($order_item);
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 + );
265 344 }
266 -
267 - return implode(', ',$order_item_data);
268 - }
345 + return implode( ', ', $parts );
346 + },
269 347 ),
270 348 'order_first_item_name' => array(
271 - 'preview' => '',
272 - 'label' => 'Order first item name',
273 - 'return_type' => 'text',
274 - 'value' => function ($order, $field_function) {
275 - foreach ($order->get_items() as $item) {
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 ) {
276 355 return $item->get_name();
277 356 }
278 357 return '';
279 - }
358 + },
280 359 ),
281 360 'order_first_item_price' => array(
282 - 'preview' => '',
283 - 'label' => 'Order first item price',
284 - 'return_type' => 'text',
285 - 'value' => function ($order, $field_function) {
286 - foreach ($order->get_items() as $item) {
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 ) {
287 368 $product = $item->get_product();
288 369 if ( $product ) {
289 - return wc_price( $product->get_price(), array( 'currency' => $order->get_currency() ) );
370 + return html_entity_decode( wc_price( $product->get_price(), array( 'currency' => $currency ) ) );
290 371 }
291 372 break;
292 373 }
293 374 return '';
294 - }
375 + },
295 376 ),
296 377 'order_first_item_total' => array(
297 - 'preview' => '',
298 - 'label' => 'Order first item total',
299 - 'return_type' => 'text',
300 - 'value' => function ($order, $field_function) {
301 - foreach ($order->get_items() as $item) {
302 - return wc_price( $item->get_total(), array( 'currency' => $order->get_currency() ));
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 ) ) );
303 386 }
304 387 return '';
305 - }
388 + },
306 389 ),
307 390 );
308 391
309 - $woo_fields['WooCommerce Customer'] = array (
310 - 'billing_first_name' => array('preview' => 'John'),
311 - 'billing_last_name' => array('preview' => 'Doe'),
312 - 'billing_company' => array('preview' => 'ABC Inc'),
313 - 'billing_address_1' => array('preview' => '123, XYZ Street'),
314 - 'billing_address_2' => array('preview' => 'XYZ Avenue'),
315 - 'billing_city' => array('preview' => 'New York'),
316 - 'billing_postcode' => array('preview' => '12345'),
317 - 'billing_state' => array(
318 - 'preview' => 'NY',
319 - 'value' => function ($order, $field_function) {
320 - $country_function = str_replace('state', 'country', $field_function);
321 - $state_code = $order->$field_function();
322 - $country_code = $order->$country_function();
323 - return WC()->countries->states[$country_code][$state_code];
324 - }
392 + $woo_fields['WooCommerce Customer'] = array(
393 + 'billing_first_name' => array(
394 + 'preview' => 'John',
395 + 'source' => 'base',
396 + 'path' => 'billing.first_name',
325 397 ),
326 - 'billing_country' => array(
327 - 'preview' => 'US',
328 - 'value' => function ($order, $field_function) {
329 - return WC()->countries->countries[$order->$field_function()];
330 - }
398 + 'billing_last_name' => array(
399 + 'preview' => 'Doe',
400 + 'source' => 'base',
401 + 'path' => 'billing.last_name',
331 402 ),
332 - 'billing_email' => array('preview' => '[email protected]'),
333 - 'billing_phone' => array('preview' => '987 654 321'),
334 - 'formatted_billing_address' => array(
335 - 'preview' => '',
336 - 'label' => 'Complete billing address',
337 - 'value' => function ($order, $field_function) {
338 - return str_replace('<br/>', ', ', $order->$field_function());
339 - }
403 + 'billing_company' => array(
404 + 'preview' => 'ABC Inc',
405 + 'source' => 'base',
406 + 'path' => 'billing.company',
340 407 ),
341 - 'shipping_first_name' => array('preview' => 'John'),
342 - 'shipping_last_name' => array('preview' => 'Doe'),
343 - 'shipping_company' => array('preview' => 'ABC Inc'),
344 - 'shipping_address_1' => array('preview' => '123, XYZ Street'),
345 - 'shipping_address_2' => array('preview' => 'XYZ Avenue'),
346 - 'shipping_city' => array('preview' => 'New York'),
347 - 'shipping_postcode' => array('preview' => '12345'),
348 - 'shipping_state' => array(
349 - 'preview' => 'NY',
350 - 'value' => function ($order, $field_function) {
351 - $country_function = str_replace('state', 'country', $field_function);
352 - $state_code = $order->$field_function();
353 - $country_code = $order->$country_function();
354 - return WC()->countries->states[$country_code][$state_code];
355 - }
408 + 'billing_address_1' => array(
409 + 'preview' => '123, XYZ Street',
410 + 'source' => 'base',
411 + 'path' => 'billing.address_1',
356 412 ),
357 - 'shipping_country' => array(
358 - 'preview' => 'US',
359 - 'value' => '',
360 - 'value' => function ($order, $field_function) {
361 - return WC()->countries->countries[$order->$field_function()];
362 - }
413 + 'billing_address_2' => array(
414 + 'preview' => 'XYZ Avenue',
415 + 'source' => 'base',
416 + 'path' => 'billing.address_2',
363 417 ),
364 - 'shipping_phone' => array('preview' => '987 654 321'),
365 - 'formatted_shipping_address' => array(
366 - 'preview' => '',
367 - 'label' => 'Complete shipping address',
368 - 'value' => function ($order, $field_function) {
369 - return str_replace('<br/>', ', ', $order->$field_function());
370 - }
371 - )
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' => '[email protected]',
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 + ),
372 542 );
373 543
374 - foreach ($woo_fields as $woo_field_key => $woo_field) {
375 - foreach ($woo_field as $field => $field_data) {
376 - if (isset($field_data['label'])) {
377 - $label = $field_data['label'];
378 - } else {
379 - $label = ucfirst( str_replace('_', ' ', $field) );
380 - }
381 -
382 - $merge_tags[$woo_field_key][] = array(
383 - 'id' => 'woo_order_' . $field,
384 - 'label' => $label,
385 - 'preview_value' => isset($field_data['preview']) ? $field_data['preview'] : '',
386 - 'return_type' => isset($field_data['return_type']) ? $field_data['return_type'] : 'text',
387 - 'value' => function ($args) use ($field, $field_data) {
388 - $order = wc_get_order( $args['object_id'] );
389 - $field_function = 'get_' . $field;
390 - if (isset($field_data['value'])) {
391 - $value = $field_data['value']($order, $field_function);
392 - } else {
393 - $value = $order->$field_function();
394 - }
395 - return html_entity_decode(sanitize_text_field($value));
396 - }
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,
397 555 );
398 556 }
399 557 }
400 558
401 - // Order meta keys
402 - $order_meta_keys = Notifier_Notification_Merge_Tags::get_custom_meta_keys('shop_order');
403 - if(!empty($order_meta_keys)){
404 - 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 ) {
405 563 $merge_tags['WooCommerce Order Custom Meta'][] = array(
406 - 'id' => 'woo_order_meta_' . $order_meta_key,
407 - 'label' => $order_meta_key,
564 + 'id' => 'woo_order_meta_' . $order_meta_key,
565 + 'label' => $order_meta_key,
408 566 'preview_value' => '123',
409 - 'return_type' => 'all',
410 - 'value' => function ($args) use ($order_meta_key) {
411 - $order = wc_get_order( $args['object_id'] );
412 - $value = $order->get_meta( $order_meta_key );
413 - if(is_array($value) || is_object($value)){
414 - $value = json_encode($value);
415 - }
416 - return (string) $value;
417 - }
567 + 'return_type' => 'all',
568 + 'source' => 'meta',
569 + 'path' => $order_meta_key,
418 570 );
419 571 }
420 572 }
421 573
422 - // WooCommerce customer user meta
574 + // WooCommerce customer user meta — read from persistent option.
423 575 $user_meta_keys = Notifier_Notification_Merge_Tags::get_user_meta_keys();
424 - if(!empty($user_meta_keys)){
425 - foreach($user_meta_keys as $user_meta_key){
576 + if ( ! empty( $user_meta_keys ) ) {
577 + foreach ( $user_meta_keys as $user_meta_key ) {
426 578 $merge_tags['WooCommerce Customer User Meta'][] = array(
427 - 'id' => 'woo_customer_meta_' . $user_meta_key,
428 - 'label' => $user_meta_key,
579 + 'id' => 'woo_customer_meta_' . $user_meta_key,
580 + 'label' => $user_meta_key,
429 581 'preview_value' => '123',
430 - 'return_type' => 'all',
431 - 'value' => function ($args) use ($user_meta_key) {
432 - $order = wc_get_order( $args['object_id'] );
433 - $user_id = $order->get_user_id();
434 - if(0 == $user_id){
435 - return '';
436 - }
437 -
438 - $value = get_user_meta($user_id, $user_meta_key, true);
439 - if(is_array($value) || is_object($value)){
440 - $value = json_encode($value);
441 - }
442 - return (string) $value;
443 - }
582 + 'return_type' => 'all',
583 + 'source' => 'user_meta',
584 + 'path' => $user_meta_key,
444 585 );
445 586 }
446 587 }
447 588
@@ -447,9 +588,9 @@
447 588
448 589 $merge_tags['WooCommerce Cart Abandonment'] = array(
449 590 array(
450 591 'id' => 'wca_cart_products_list',
451 - 'label' => 'Adandoned cart products list',
592 + 'label' => 'Abandoned cart products list',
452 593 'preview_value' => '',
453 594 'return_type' => 'text',
454 595 'value' => function ($cart_details) {
455 596 return $cart_details ? self::get_comma_separated_products(maybe_unserialize($cart_details['cart_data'])) : '';
@@ -454,20 +595,20 @@
454 595 'value' => function ($cart_details) {
455 596 return $cart_details ? self::get_comma_separated_products(maybe_unserialize($cart_details['cart_data'])) : '';
456 597 }
457 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 + ),
458 608 array(
459 - 'id' => 'wca_cart_total',
460 - 'label' => 'Adandoned cart total',
461 - 'preview_value' => '',
462 - 'return_type' => 'text',
463 - 'value' => function ($cart_details) {
464 - return $cart_entry['cart_total'] ?? '';
465 - }
466 - ),
467 - array(
468 609 'id' => 'wca_cart_datetime',
469 - 'label' => 'Adandoned cart datetime',
610 + 'label' => 'Abandoned cart datetime',
470 611 'preview_value' => '',
471 612 'return_type' => 'text',
472 613 'value' => function ($cart_details) {
473 614 return $cart_details ? $cart_details['created_time'] : '';
@@ -503,8 +644,21 @@
503 644 return wc_get_cart_url();
504 645 }
505 646 ),
506 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(
507 661 'id' => 'wca_cart_customer_email',
508 662 'label' => 'Billing email',
509 663 'preview_value' => '',
510 664 'return_type' => 'text',
@@ -518,11 +672,11 @@
518 672 'label' => 'Coupon details',
519 673 'preview_value' => '',
520 674 'return_type' => 'text',
521 675 'value' => function ($cart_details) {
522 - $coupon_data = $data ? maybe_unserialize($cart_details['coupon_content']) : [];
523 - return $coupon_data ? implode(', ', $coupon_data) : '';
524 - }
676 + $coupon_data = $cart_details ? maybe_unserialize($cart_details['coupon_content'] ?? '') : [];
677 + return $coupon_data ? implode(', ', $coupon_data) : '';
678 + }
525 679 ),
526 680 );
527 681
528 682 $wca_other_fields = array(
@@ -725,77 +879,60 @@
725 879
726 880 /*
727 881 * Add recipient fields for WooCommerce
728 882 */
729 - public static function woocommerce_recipient_fields($recipient_fields){
883 + public static function woocommerce_recipient_fields( $recipient_fields ) {
730 884 $recipient_fields['WooCommerce'] = array(
731 885 array(
732 - 'id' => 'woo_order_billing_phone',
733 - 'label' => 'Billing phone number',
734 - 'value' => function ($args) {
735 - $order = wc_get_order( $args['object_id'] );
736 - $phone_number = $order->get_billing_phone();
737 - $country_code = $order->get_billing_country();
738 - $phone_number = self::get_formatted_phone_number($phone_number, $country_code);
739 - return html_entity_decode(sanitize_text_field($phone_number));
740 - }
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 + },
741 895 ),
742 896 array(
743 - 'id' => 'woo_order_shipping_phone',
744 - 'label' => 'Shipping phone number',
745 - 'value' => function ($args) {
746 - $order = wc_get_order( $args['object_id'] );
747 - $phone_number = $order->get_shipping_phone();
748 - $country_code = $order->get_shipping_country();
749 - $phone_number = self::get_formatted_phone_number($phone_number, $country_code);
750 - return html_entity_decode(sanitize_text_field($phone_number));
751 - }
752 - )
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 + ),
753 907 );
754 908
755 - // Order meta keys
756 - $order_meta_keys = Notifier_Notification_Merge_Tags::get_custom_meta_keys('shop_order');
757 - if(!empty($order_meta_keys)){
758 - 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 ) {
759 913 $recipient_fields['WooCommerce Order Custom Meta'][] = array(
760 - 'id' => 'woo_order_meta_' . $order_meta_key,
761 - 'label' => $order_meta_key,
914 + 'id' => 'woo_order_meta_' . $order_meta_key,
915 + 'label' => $order_meta_key,
762 916 'preview_value' => '123',
763 - 'return_type' => 'text',
764 - 'value' => function ($args) use ($order_meta_key) {
765 - $order = wc_get_order( $args['object_id'] );
766 - $value = $order->get_meta( $order_meta_key );
767 - if(is_array($value) || is_object($value)){
768 - $value = json_encode($value);
769 - }
770 - return (string) $value;
771 - }
917 + 'return_type' => 'text',
918 + 'source' => 'meta',
919 + 'path' => $order_meta_key,
772 920 );
773 921 }
774 922 }
775 923
776 - // WooCommerce customer user meta
924 + // WooCommerce customer user meta — read from persistent option.
777 925 $user_meta_keys = Notifier_Notification_Merge_Tags::get_user_meta_keys();
778 - if(!empty($user_meta_keys)){
779 - foreach($user_meta_keys as $user_meta_key){
926 + if ( ! empty( $user_meta_keys ) ) {
927 + foreach ( $user_meta_keys as $user_meta_key ) {
780 928 $recipient_fields['WooCommerce Customer User Meta'][] = array(
781 - 'id' => 'woo_customer_meta_' . $user_meta_key,
782 - 'label' => $user_meta_key,
929 + 'id' => 'woo_customer_meta_' . $user_meta_key,
930 + 'label' => $user_meta_key,
783 931 'preview_value' => '123',
784 - 'return_type' => 'text',
785 - 'value' => function ($args) use ($user_meta_key) {
786 - $order = wc_get_order( $args['object_id'] );
787 - $user_id = $order->get_user_id();
788 - if(0 == $user_id){
789 - return '';
790 - }
791 -
792 - $value = get_user_meta($user_id, $user_meta_key, true);
793 - if(is_array($value) || is_object($value)){
794 - $value = json_encode($value);
795 - }
796 - return (string) $value;
797 - }
932 + 'return_type' => 'text',
933 + 'source' => 'user_meta',
934 + 'path' => $user_meta_key,
798 935 );
799 936 }
800 937 }
801 938
@@ -821,8 +958,12 @@
821 958 /**
822 959 * Get phone number with country extension code
823 960 */
824 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 + }
825 966 $phone_number = notifier_sanitize_phone_number( $phone_number );
826 967 $phone_number = ltrim($phone_number, '0');
827 968 if ( in_array( $country_code, array( 'US', 'CA' ), true ) ) {
828 969 $calling_code = '+1';
@@ -859,9 +1000,9 @@
859 1000 /**
860 1001 * Hook to save the custom field data
861 1002 */
862 1003 public static function notifier_save_checkout_field($order_id) {
863 - $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' ] ) ) : '';
864 1005 if ( !empty($opt_in) ) {
865 1006 $order = wc_get_order( $order_id );
866 1007 $order->update_meta_data( NOTIFIER_PREFIX . 'whatsapp_opt_in', $opt_in );
867 1008 $order->save();
@@ -868,30 +1009,19 @@
868 1009 }
869 1010 }
870 1011
871 1012 /**
872 - * Register order meta box
873 - */
874 - public static function register_order_meta_box() {
875 - add_meta_box(
876 - 'notifier_woocommerce_order_meta',
877 - __( 'WANotifier', 'your-textdomain' ),
878 - array( __CLASS__, 'edit_admin_order_meta' ),
879 - 'shop_order',
880 - 'side',
881 - 'default'
882 - );
883 - }
884 -
885 - /**
886 - * Display checkbox field on order page
887 - */
888 - public static function edit_admin_order_meta( $post ){
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 ) {
889 1017 if ( 'yes' !== get_option( NOTIFIER_PREFIX . 'enable_opt_in_checkbox_checkout' ) ) {
890 1018 return;
891 1019 }
892 1020
893 - $order = wc_get_order( $post->ID ); // Get the order from post ID
1021 + if ( ! is_a( $order, 'WC_Order' ) ) {
1022 + $order = wc_get_order( $order->ID );
1023 + }
894 1024 if ( ! $order ) {
895 1025 return;
896 1026 }
897 1027
@@ -900,16 +1030,17 @@
900 1030 $checkbox_text = 'Receive updates on WhatsApp';
901 1031 }
902 1032
903 1033 $opt_in = $order->get_meta( NOTIFIER_PREFIX . 'whatsapp_opt_in' );
904 - $checked = checked( $opt_in, '1', false );
905 1034
906 - echo '<p>';
907 - echo '<label for="' . esc_attr( NOTIFIER_PREFIX . 'whatsapp_opt_in' ) . '">';
908 - echo '<input type="checkbox" name="' . esc_attr( NOTIFIER_PREFIX . 'whatsapp_opt_in' ) . '" id="' . esc_attr( NOTIFIER_PREFIX . 'whatsapp_opt_in' ) . '" value="1" ' . $checked . ' />';
909 - echo '&nbsp;' . esc_html( $checkbox_text );
910 - echo '</label>';
911 - echo '</p>';
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 + ) );
912 1043 }
913 1044
914 1045 /**
915 1046 * Hook to save the custom field data on order page
@@ -914,9 +1045,20 @@
914 1045 /**
915 1046 * Hook to save the custom field data on order page
916 1047 */
917 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 +
918 1057 $order = wc_get_order( $post_id );
1058 + if ( ! $order ) {
1059 + return;
1060 + }
919 1061
920 1062 $opt_in = isset( $_POST[ NOTIFIER_PREFIX . 'whatsapp_opt_in' ] ) ? '1' : '';
921 1063 $order->update_meta_data( NOTIFIER_PREFIX . 'whatsapp_opt_in', $opt_in );
922 1064 $order->save();
@@ -1023,11 +1165,30 @@
1023 1165 if ($existing_entry && $existing_entry->triggered) {
1024 1166 wp_send_json(array('error' => true, 'message' => 'Cart abandonment already processed.' ));
1025 1167 }
1026 1168
1027 - // Determine created_time
1028 - $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 + );
1029 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 +
1030 1191 // Update or insert cart and customer data into the table
1031 1192 if ($existing_entry) {
1032 1193 // Update the existing entry
1033 1194 $wpdb->update(
@@ -1254,9 +1415,520 @@
1254 1415 }
1255 1416 }
1256 1417 }
1257 1418
1419 + // ========================================
1420 + // v3 PAYLOAD BUILDER
1421 + // ========================================
1422 +
1258 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 + /**
1259 1931 * store session id during order creation
1260 1932 */
1261 1933 public static function notifier_save_session_on_new_order($order_id) {
1262 1934 if ('yes' !== get_option('notifier_enable_abandonment_cart_tracking', 'no')) {
@@ -1282,6 +1954,5 @@
1282 1954 );
1283 1955 as_unschedule_all_actions('notifier_check_cart_abandonment', array(sanitize_key($session_key)));
1284 1956 }
1285 1957 }
1286 -
1287 1958 }