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 +985 -312 2.7.93.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,285 +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 - foreach ( $order->get_items() as $item ) {
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 ) {
237 322 $product = $item->get_product();
238 323 if ( $product ) {
239 324 $image_id = $product->get_image_id();
240 - if ( $image_id ) {
241 - return wp_get_attachment_url( $image_id );
242 - } else {
243 - return wc_placeholder_img_src();
244 - }
325 + return $image_id ? wp_get_attachment_url( $image_id ) : wc_placeholder_img_src();
245 326 }
246 327 break;
247 328 }
248 329 return wc_placeholder_img_src();
249 - }
330 + },
250 331 ),
251 332 'products_list' => array(
252 - 'preview' => '',
253 - 'label' => 'Order products list',
254 - 'return_type' => 'text',
255 - 'value' => function ($order, $field_function) {
256 - $order_item_data = array();
257 - foreach ( $order->get_items() as $item ) {
258 - $order_item = $item->get_name().' x '.$item->get_quantity().' ('.wc_price( $item->get_total() ).')';
259 - $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 + );
260 344 }
261 -
262 - return implode(', ',$order_item_data);
263 - }
345 + return implode( ', ', $parts );
346 + },
264 347 ),
265 348 'order_first_item_name' => array(
266 - 'preview' => '',
267 - 'label' => 'Order first item name',
268 - 'return_type' => 'text',
269 - 'value' => function ($order, $field_function) {
270 - 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 ) {
271 355 return $item->get_name();
272 356 }
273 357 return '';
274 - }
358 + },
275 359 ),
276 360 'order_first_item_price' => array(
277 - 'preview' => '',
278 - 'label' => 'Order first item price',
279 - 'return_type' => 'text',
280 - 'value' => function ($order, $field_function) {
281 - 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 ) {
282 368 $product = $item->get_product();
283 369 if ( $product ) {
284 - return wc_price( $product->get_price(), array( 'currency' => $order->get_currency() ) );
370 + return html_entity_decode( wc_price( $product->get_price(), array( 'currency' => $currency ) ) );
285 371 }
286 372 break;
287 373 }
288 374 return '';
289 - }
375 + },
290 376 ),
291 377 'order_first_item_total' => array(
292 - 'preview' => '',
293 - 'label' => 'Order first item total',
294 - 'return_type' => 'text',
295 - 'value' => function ($order, $field_function) {
296 - foreach ($order->get_items() as $item) {
297 - 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 ) ) );
298 386 }
299 387 return '';
300 - }
388 + },
301 389 ),
302 390 );
303 391
304 - $woo_fields['WooCommerce Customer'] = array (
305 - 'billing_first_name' => array('preview' => 'John'),
306 - 'billing_last_name' => array('preview' => 'Doe'),
307 - 'billing_company' => array('preview' => 'ABC Inc'),
308 - 'billing_address_1' => array('preview' => '123, XYZ Street'),
309 - 'billing_address_2' => array('preview' => 'XYZ Avenue'),
310 - 'billing_city' => array('preview' => 'New York'),
311 - 'billing_postcode' => array('preview' => '12345'),
312 - 'billing_state' => array(
313 - 'preview' => 'NY',
314 - 'value' => function ($order, $field_function) {
315 - $country_function = str_replace('state', 'country', $field_function);
316 - $state_code = $order->$field_function();
317 - $country_code = $order->$country_function();
318 - return WC()->countries->states[$country_code][$state_code];
319 - }
392 + $woo_fields['WooCommerce Customer'] = array(
393 + 'billing_first_name' => array(
394 + 'preview' => 'John',
395 + 'source' => 'base',
396 + 'path' => 'billing.first_name',
320 397 ),
321 - 'billing_country' => array(
322 - 'preview' => 'US',
323 - 'value' => function ($order, $field_function) {
324 - return WC()->countries->countries[$order->$field_function()];
325 - }
398 + 'billing_last_name' => array(
399 + 'preview' => 'Doe',
400 + 'source' => 'base',
401 + 'path' => 'billing.last_name',
326 402 ),
327 - 'billing_email' => array('preview' => 'john@example.com'),
328 - 'billing_phone' => array('preview' => '987 654 321'),
329 - 'formatted_billing_address' => array(
330 - 'preview' => '',
331 - 'label' => 'Complete billing address',
332 - 'value' => function ($order, $field_function) {
333 - return str_replace('<br/>', ', ', $order->$field_function());
334 - }
403 + 'billing_company' => array(
404 + 'preview' => 'ABC Inc',
405 + 'source' => 'base',
406 + 'path' => 'billing.company',
335 407 ),
336 - 'shipping_first_name' => array('preview' => 'John'),
337 - 'shipping_last_name' => array('preview' => 'Doe'),
338 - 'shipping_company' => array('preview' => 'ABC Inc'),
339 - 'shipping_address_1' => array('preview' => '123, XYZ Street'),
340 - 'shipping_address_2' => array('preview' => 'XYZ Avenue'),
341 - 'shipping_city' => array('preview' => 'New York'),
342 - 'shipping_postcode' => array('preview' => '12345'),
343 - 'shipping_state' => array(
344 - 'preview' => 'NY',
345 - 'value' => function ($order, $field_function) {
346 - $country_function = str_replace('state', 'country', $field_function);
347 - $state_code = $order->$field_function();
348 - $country_code = $order->$country_function();
349 - return WC()->countries->states[$country_code][$state_code];
350 - }
408 + 'billing_address_1' => array(
409 + 'preview' => '123, XYZ Street',
410 + 'source' => 'base',
411 + 'path' => 'billing.address_1',
351 412 ),
352 - 'shipping_country' => array(
353 - 'preview' => 'US',
354 - 'value' => '',
355 - 'value' => function ($order, $field_function) {
356 - return WC()->countries->countries[$order->$field_function()];
357 - }
413 + 'billing_address_2' => array(
414 + 'preview' => 'XYZ Avenue',
415 + 'source' => 'base',
416 + 'path' => 'billing.address_2',
358 417 ),
359 - 'shipping_phone' => array('preview' => '987 654 321'),
360 - 'formatted_shipping_address' => array(
361 - 'preview' => '',
362 - 'label' => 'Complete shipping address',
363 - 'value' => function ($order, $field_function) {
364 - return str_replace('<br/>', ', ', $order->$field_function());
365 - }
366 - )
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 + ),
367 542 );
368 543
369 - foreach ($woo_fields as $woo_field_key => $woo_field) {
370 - foreach ($woo_field as $field => $field_data) {
371 - if (isset($field_data['label'])) {
372 - $label = $field_data['label'];
373 - } else {
374 - $label = ucfirst( str_replace('_', ' ', $field) );
375 - }
376 -
377 - $merge_tags[$woo_field_key][] = array(
378 - 'id' => 'woo_order_' . $field,
379 - 'label' => $label,
380 - 'preview_value' => isset($field_data['preview']) ? $field_data['preview'] : '',
381 - 'return_type' => isset($field_data['return_type']) ? $field_data['return_type'] : 'text',
382 - 'value' => function ($args) use ($field, $field_data) {
383 - $order = wc_get_order( $args['object_id'] );
384 - $field_function = 'get_' . $field;
385 - if (isset($field_data['value'])) {
386 - $value = $field_data['value']($order, $field_function);
387 - } else {
388 - $value = $order->$field_function();
389 - }
390 - return html_entity_decode(sanitize_text_field($value));
391 - }
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,
392 555 );
393 556 }
394 557 }
395 558
396 - // Order meta keys
397 - $order_meta_keys = Notifier_Notification_Merge_Tags::get_custom_meta_keys('shop_order');
398 - if(!empty($order_meta_keys)){
399 - 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 ) {
400 563 $merge_tags['WooCommerce Order Custom Meta'][] = array(
401 - 'id' => 'woo_order_meta_' . $order_meta_key,
402 - 'label' => $order_meta_key,
564 + 'id' => 'woo_order_meta_' . $order_meta_key,
565 + 'label' => $order_meta_key,
403 566 'preview_value' => '123',
404 - 'return_type' => 'all',
405 - 'value' => function ($args) use ($order_meta_key) {
406 - $order = wc_get_order( $args['object_id'] );
407 - $value = $order->get_meta( $order_meta_key );
408 - if(is_array($value) || is_object($value)){
409 - $value = json_encode($value);
410 - }
411 - return (string) $value;
412 - }
567 + 'return_type' => 'all',
568 + 'source' => 'meta',
569 + 'path' => $order_meta_key,
413 570 );
414 571 }
415 572 }
416 573
417 - // WooCommerce customer user meta
574 + // WooCommerce customer user meta — read from persistent option.
418 575 $user_meta_keys = Notifier_Notification_Merge_Tags::get_user_meta_keys();
419 - if(!empty($user_meta_keys)){
420 - foreach($user_meta_keys as $user_meta_key){
576 + if ( ! empty( $user_meta_keys ) ) {
577 + foreach ( $user_meta_keys as $user_meta_key ) {
421 578 $merge_tags['WooCommerce Customer User Meta'][] = array(
422 - 'id' => 'woo_customer_meta_' . $user_meta_key,
423 - 'label' => $user_meta_key,
579 + 'id' => 'woo_customer_meta_' . $user_meta_key,
580 + 'label' => $user_meta_key,
424 581 'preview_value' => '123',
425 - 'return_type' => 'all',
426 - 'value' => function ($args) use ($user_meta_key) {
427 - $order = wc_get_order( $args['object_id'] );
428 - $user_id = $order->get_user_id();
429 - if(0 == $user_id){
430 - return '';
431 - }
432 -
433 - $value = get_user_meta($user_id, $user_meta_key, true);
434 - if(is_array($value) || is_object($value)){
435 - $value = json_encode($value);
436 - }
437 - return (string) $value;
438 - }
582 + 'return_type' => 'all',
583 + 'source' => 'user_meta',
584 + 'path' => $user_meta_key,
439 585 );
440 586 }
441 587 }
442 588
@@ -442,9 +588,9 @@
442 588
443 589 $merge_tags['WooCommerce Cart Abandonment'] = array(
444 590 array(
445 591 'id' => 'wca_cart_products_list',
446 - 'label' => 'Adandoned cart products list',
592 + 'label' => 'Abandoned cart products list',
447 593 'preview_value' => '',
448 594 'return_type' => 'text',
449 595 'value' => function ($cart_details) {
450 596 return $cart_details ? self::get_comma_separated_products(maybe_unserialize($cart_details['cart_data'])) : '';
@@ -449,20 +595,20 @@
449 595 'value' => function ($cart_details) {
450 596 return $cart_details ? self::get_comma_separated_products(maybe_unserialize($cart_details['cart_data'])) : '';
451 597 }
452 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 + ),
453 608 array(
454 - 'id' => 'wca_cart_total',
455 - 'label' => 'Adandoned cart total',
456 - 'preview_value' => '',
457 - 'return_type' => 'text',
458 - 'value' => function ($cart_details) {
459 - return $cart_entry['cart_total'] ?? '';
460 - }
461 - ),
462 - array(
463 609 'id' => 'wca_cart_datetime',
464 - 'label' => 'Adandoned cart datetime',
610 + 'label' => 'Abandoned cart datetime',
465 611 'preview_value' => '',
466 612 'return_type' => 'text',
467 613 'value' => function ($cart_details) {
468 614 return $cart_details ? $cart_details['created_time'] : '';
@@ -498,8 +644,21 @@
498 644 return wc_get_cart_url();
499 645 }
500 646 ),
501 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(
502 661 'id' => 'wca_cart_customer_email',
503 662 'label' => 'Billing email',
504 663 'preview_value' => '',
505 664 'return_type' => 'text',
@@ -513,11 +672,11 @@
513 672 'label' => 'Coupon details',
514 673 'preview_value' => '',
515 674 'return_type' => 'text',
516 675 'value' => function ($cart_details) {
517 - $coupon_data = $data ? maybe_unserialize($cart_details['coupon_content']) : [];
518 - return $coupon_data ? implode(', ', $coupon_data) : '';
519 - }
676 + $coupon_data = $cart_details ? maybe_unserialize($cart_details['coupon_content'] ?? '') : [];
677 + return $coupon_data ? implode(', ', $coupon_data) : '';
678 + }
520 679 ),
521 680 );
522 681
523 682 $wca_other_fields = array(
@@ -720,77 +879,60 @@
720 879
721 880 /*
722 881 * Add recipient fields for WooCommerce
723 882 */
724 - public static function woocommerce_recipient_fields($recipient_fields){
883 + public static function woocommerce_recipient_fields( $recipient_fields ) {
725 884 $recipient_fields['WooCommerce'] = array(
726 885 array(
727 - 'id' => 'woo_order_billing_phone',
728 - 'label' => 'Billing phone number',
729 - 'value' => function ($args) {
730 - $order = wc_get_order( $args['object_id'] );
731 - $phone_number = $order->get_billing_phone();
732 - $country_code = $order->get_billing_country();
733 - $phone_number = self::get_formatted_phone_number($phone_number, $country_code);
734 - return html_entity_decode(sanitize_text_field($phone_number));
735 - }
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 + },
736 895 ),
737 896 array(
738 - 'id' => 'woo_order_shipping_phone',
739 - 'label' => 'Shipping phone number',
740 - 'value' => function ($args) {
741 - $order = wc_get_order( $args['object_id'] );
742 - $phone_number = $order->get_shipping_phone();
743 - $country_code = $order->get_shipping_country();
744 - $phone_number = self::get_formatted_phone_number($phone_number, $country_code);
745 - return html_entity_decode(sanitize_text_field($phone_number));
746 - }
747 - )
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 + ),
748 907 );
749 908
750 - // Order meta keys
751 - $order_meta_keys = Notifier_Notification_Merge_Tags::get_custom_meta_keys('shop_order');
752 - if(!empty($order_meta_keys)){
753 - 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 ) {
754 913 $recipient_fields['WooCommerce Order Custom Meta'][] = array(
755 - 'id' => 'woo_order_meta_' . $order_meta_key,
756 - 'label' => $order_meta_key,
914 + 'id' => 'woo_order_meta_' . $order_meta_key,
915 + 'label' => $order_meta_key,
757 916 'preview_value' => '123',
758 - 'return_type' => 'text',
759 - 'value' => function ($args) use ($order_meta_key) {
760 - $order = wc_get_order( $args['object_id'] );
761 - $value = $order->get_meta( $order_meta_key );
762 - if(is_array($value) || is_object($value)){
763 - $value = json_encode($value);
764 - }
765 - return (string) $value;
766 - }
917 + 'return_type' => 'text',
918 + 'source' => 'meta',
919 + 'path' => $order_meta_key,
767 920 );
768 921 }
769 922 }
770 923
771 - // WooCommerce customer user meta
924 + // WooCommerce customer user meta — read from persistent option.
772 925 $user_meta_keys = Notifier_Notification_Merge_Tags::get_user_meta_keys();
773 - if(!empty($user_meta_keys)){
774 - foreach($user_meta_keys as $user_meta_key){
926 + if ( ! empty( $user_meta_keys ) ) {
927 + foreach ( $user_meta_keys as $user_meta_key ) {
775 928 $recipient_fields['WooCommerce Customer User Meta'][] = array(
776 - 'id' => 'woo_customer_meta_' . $user_meta_key,
777 - 'label' => $user_meta_key,
929 + 'id' => 'woo_customer_meta_' . $user_meta_key,
930 + 'label' => $user_meta_key,
778 931 'preview_value' => '123',
779 - 'return_type' => 'text',
780 - 'value' => function ($args) use ($user_meta_key) {
781 - $order = wc_get_order( $args['object_id'] );
782 - $user_id = $order->get_user_id();
783 - if(0 == $user_id){
784 - return '';
785 - }
786 -
787 - $value = get_user_meta($user_id, $user_meta_key, true);
788 - if(is_array($value) || is_object($value)){
789 - $value = json_encode($value);
790 - }
791 - return (string) $value;
792 - }
932 + 'return_type' => 'text',
933 + 'source' => 'user_meta',
934 + 'path' => $user_meta_key,
793 935 );
794 936 }
795 937 }
796 938
@@ -858,9 +1000,9 @@
858 1000 /**
859 1001 * Hook to save the custom field data
860 1002 */
861 1003 public static function notifier_save_checkout_field($order_id) {
862 - $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' ] ) ) : '';
863 1005 if ( !empty($opt_in) ) {
864 1006 $order = wc_get_order( $order_id );
865 1007 $order->update_meta_data( NOTIFIER_PREFIX . 'whatsapp_opt_in', $opt_in );
866 1008 $order->save();
@@ -867,30 +1009,19 @@
867 1009 }
868 1010 }
869 1011
870 1012 /**
871 - * Register order meta box
872 - */
873 - public static function register_order_meta_box() {
874 - add_meta_box(
875 - 'notifier_woocommerce_order_meta',
876 - __( 'WANotifier', 'your-textdomain' ),
877 - array( __CLASS__, 'edit_admin_order_meta' ),
878 - 'shop_order',
879 - 'side',
880 - 'default'
881 - );
882 - }
883 -
884 - /**
885 - * Display checkbox field on order page
886 - */
887 - 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 ) {
888 1017 if ( 'yes' !== get_option( NOTIFIER_PREFIX . 'enable_opt_in_checkbox_checkout' ) ) {
889 1018 return;
890 1019 }
891 1020
892 - $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 + }
893 1024 if ( ! $order ) {
894 1025 return;
895 1026 }
896 1027
@@ -899,16 +1030,17 @@
899 1030 $checkbox_text = 'Receive updates on WhatsApp';
900 1031 }
901 1032
902 1033 $opt_in = $order->get_meta( NOTIFIER_PREFIX . 'whatsapp_opt_in' );
903 - $checked = checked( $opt_in, '1', false );
904 1034
905 - echo '<p>';
906 - echo '<label for="' . esc_attr( NOTIFIER_PREFIX . 'whatsapp_opt_in' ) . '">';
907 - echo '<input type="checkbox" name="' . esc_attr( NOTIFIER_PREFIX . 'whatsapp_opt_in' ) . '" id="' . esc_attr( NOTIFIER_PREFIX . 'whatsapp_opt_in' ) . '" value="1" ' . $checked . ' />';
908 - echo '&nbsp;' . esc_html( $checkbox_text );
909 - echo '</label>';
910 - 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 + ) );
911 1043 }
912 1044
913 1045 /**
914 1046 * Hook to save the custom field data on order page
@@ -913,9 +1045,20 @@
913 1045 /**
914 1046 * Hook to save the custom field data on order page
915 1047 */
916 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 +
917 1057 $order = wc_get_order( $post_id );
1058 + if ( ! $order ) {
1059 + return;
1060 + }
918 1061
919 1062 $opt_in = isset( $_POST[ NOTIFIER_PREFIX . 'whatsapp_opt_in' ] ) ? '1' : '';
920 1063 $order->update_meta_data( NOTIFIER_PREFIX . 'whatsapp_opt_in', $opt_in );
921 1064 $order->save();
@@ -1022,11 +1165,30 @@
1022 1165 if ($existing_entry && $existing_entry->triggered) {
1023 1166 wp_send_json(array('error' => true, 'message' => 'Cart abandonment already processed.' ));
1024 1167 }
1025 1168
1026 - // Determine created_time
1027 - $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 + );
1028 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 +
1029 1191 // Update or insert cart and customer data into the table
1030 1192 if ($existing_entry) {
1031 1193 // Update the existing entry
1032 1194 $wpdb->update(
@@ -1252,8 +1414,519 @@
1252 1414 }
1253 1415 }
1254 1416 }
1255 1417 }
1418 +
1419 + // ========================================
1420 + // v3 PAYLOAD BUILDER
1421 + // ========================================
1422 +
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 + }
1256 1929
1257 1930 /**
1258 1931 * store session id during order creation
1259 1932 */