api = $api; $this->logger = $logger; } /** * Return values that may be selected for a WhatsApp template variable. * * These identifiers are persisted in the database, so they deliberately use * stable, non-translated keys. */ public static function allowed_mapping_fields() : array { return array( 'customer_name', 'billing_first_name', 'billing_last_name', 'order_id', 'order_number', 'product_names', 'order_total', 'currency', 'order_status', 'billing_phone', 'billing_email', 'store_name', 'store_url', ); } /** * Normalize mappings submitted by the automation editor. * * Authkey template variables use positive numeric placeholders, such as * {{1}}. Values are limited to fields supported by this plugin; unknown * values are discarded rather than being stored for later use. */ public static function sanitize_mappings( array $mappings ) : array { $sanitized = array(); $allowed = array_flip( self::allowed_mapping_fields() ); foreach ( $mappings as $variable => $field ) { $variable = (string) $variable; if ( ! preg_match( '/^[1-9][0-9]*$/', $variable ) || ! is_scalar( $field ) ) { continue; } $field = sanitize_key( (string) $field ); if ( isset( $allowed[ $field ] ) ) { $sanitized[ $variable ] = $field; } } return $sanitized; } public function register_hooks() : void { add_action( 'woocommerce_new_order', fn( $order_id ) => $this->dispatch( 'order_created', absint( $order_id ) ) ); add_action( 'woocommerce_checkout_order_processed', array( $this, 'checkout_processed' ), 10, 1 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'checkout_processed' ), 10, 1 ); add_action( 'woocommerce_order_status_changed', function( $order_id, $from, $to ) : void { $order_id = absint( $order_id ); $from = sanitize_key( (string) $from ); $to = sanitize_key( (string) $to ); $this->dispatch( 'status_' . $to, $order_id ); $this->dispatch( 'transition_' . $from . '_to_' . $to, $order_id ); if ( 'failed' === $to ) { $this->dispatch( 'payment_failed', $order_id ); } }, 10, 3 ); add_action( 'woocommerce_payment_complete', fn( $order_id ) => $this->dispatch( 'payment_success', absint( $order_id ) ) ); add_action( 'user_register', fn( $user_id ) => $this->dispatch( 'customer_registered', 0, array( 'user_id' => absint( $user_id ) ) ) ); add_action( 'wp_login', fn( $login, $user ) => $this->dispatch( 'customer_login', 0, array( 'user_id' => absint( $user->ID ?? 0 ) ) ), 10, 2 ); } /** * Dispatch order events that are created as checkout finishes. * * WooCommerce's `woocommerce_applied_coupon` hook only receives a coupon * code. At that point it has no order or billing recipient, so using it for * message delivery is not valid. The completed order provides both. */ public function checkout_processed( $order_or_id ) : void { $order = is_object( $order_or_id ) && is_callable( array( $order_or_id, 'get_id' ) ) ? $order_or_id : null; $order_id = $order ? absint( $order->get_id() ) : absint( $order_or_id ); if ( ! $order && function_exists( 'wc_get_order' ) ) { $order = wc_get_order( $order_id ); } if ( ! $order_id || ! $order ) { return; } $this->dispatch( 'checkout_completed', $order_id ); if ( $order && ! empty( $order->get_coupon_codes() ) ) { $this->dispatch( 'coupon_used', $order_id ); } } public function dispatch( string $trigger, int $order_id = 0, array $context = array() ) : void { global $wpdb; $rules = $wpdb->get_results( $wpdb->prepare( 'SELECT * FROM ' . Database::table( 'automations' ) . ' WHERE enabled = 1 AND trigger_name = %s', $trigger ) ); foreach ( $rules as $rule ) { if ( ! $this->matches( $rule, $order_id ) ) { continue; } if ( $rule->delay_minutes > 0 ) { wp_schedule_single_event( time() + ( MINUTE_IN_SECONDS * (int) $rule->delay_minutes ), 'authkey_send_automation', array( (int) $rule->id, $order_id ) ); continue; } $this->send_rule( $rule, $order_id, $context ); } } public function run_scheduled( int $rule_id, int $order_id ) : void { global $wpdb; $rule = $wpdb->get_row( $wpdb->prepare( 'SELECT * FROM ' . Database::table( 'automations' ) . ' WHERE id = %d AND enabled = 1', $rule_id ) ); if ( $rule ) { $this->send_rule( $rule, $order_id ); } } private function matches( object $rule, int $order_id ) : bool { $conditions = json_decode( $rule->conditions ?: '[]', true ); if ( empty( $conditions ) || ! $order_id || ! function_exists( 'wc_get_order' ) ) { return true; } $order = wc_get_order( $order_id ); if ( ! $order ) { return false; } foreach ( $conditions as $condition ) { if ( empty( $condition['field'] ) ) { continue; } if ( 'total_gt' === $condition['field'] && $order->get_total() <= (float) $condition['value'] ) { return false; } if ( 'total_lt' === $condition['field'] && $order->get_total() >= (float) $condition['value'] ) { return false; } if ( 'country' === $condition['field'] && $order->get_billing_country() !== $condition['value'] ) { return false; } if ( 'payment_method' === $condition['field'] && $order->get_payment_method() !== $condition['value'] ) { return false; } } return true; } private function send_rule( object $rule, int $order_id, array $context = array() ) : void { $order = $order_id && function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : null; $mobile = $this->recipient( $rule, $order, $context ); $mappings = json_decode( $rule->mappings ?: '{}', true ); $mappings = self::sanitize_mappings( is_array( $mappings ) ? $mappings : array() ); $values = array(); foreach ( $mappings as $variable => $field ) { $values[ $variable ] = $this->value( $field, $order, $context ); } if ( '' === $mobile ) { $this->logger->log( array( 'order_id' => $order_id, 'customer' => $order ? $order->get_formatted_billing_full_name() : '', 'template' => $rule->template, 'trigger' => $rule->trigger_name, 'status' => 'skipped', 'response' => array( 'message' => __( 'No recipient mobile number is available for this automation.', 'authkey' ) ), 'retry_count' => 0, ) ); return; } $template_id = absint( $rule->template_id ?? 0 ); $result = $this->api->send( $mobile, $template_id, $values ); if ( is_wp_error( $result ) && 'yes' === Settings::get( 'retry', 'yes' ) ) { $result = $this->api->send( $mobile, $template_id, $values ); } $this->logger->log( array( 'order_id' => $order_id, 'customer' => $order ? $order->get_formatted_billing_full_name() : '', 'template' => $rule->template, 'trigger' => $rule->trigger_name, 'status' => is_wp_error( $result ) ? 'failed' : 'sent', 'response' => is_wp_error( $result ) ? array( 'message' => $result->get_error_message() ) : $result, 'retry_count' => is_wp_error( $result ) ? 1 : 0, ) ); } private function recipient( object $rule, $order, array $context ) : string { if ( 'custom' === $rule->recipient_type ) { return (string) $rule->recipient_value; } if ( 'meta' === $rule->recipient_type && $order ) { $value = $order->get_meta( $rule->recipient_value ); return is_scalar( $value ) ? (string) $value : ''; } if ( $order && 'shipping' === $rule->recipient_type ) { return (string) $order->get_shipping_phone(); } if ( $order ) { return (string) $order->get_billing_phone(); } return $this->user_meta( $context, 'billing_phone' ); } private function value( string $field, $order, array $context ) : string { $map = array( 'order_id' => fn() => $order ? $order->get_id() : '', 'order_number' => fn() => $order ? $order->get_order_number() : '', 'customer_name' => fn() => $order ? $order->get_formatted_billing_full_name() : $this->user_name( $context ), 'billing_first_name' => fn() => $order ? $order->get_billing_first_name() : $this->user_meta( $context, 'billing_first_name' ), 'billing_last_name' => fn() => $order ? $order->get_billing_last_name() : $this->user_meta( $context, 'billing_last_name' ), 'billing_phone' => fn() => $order ? $order->get_billing_phone() : $this->user_meta( $context, 'billing_phone' ), 'billing_email' => fn() => $order ? $order->get_billing_email() : $this->user_email( $context ), 'product_names' => fn() => $order ? implode( ', ', array_map( fn( $item ) => $item->get_name(), $order->get_items() ) ) : '', 'order_total' => fn() => $order ? $order->get_total() : '', 'order_status' => fn() => $order ? $order->get_status() : '', 'currency' => fn() => $order ? $order->get_currency() : '', 'store_name' => fn() => get_bloginfo( 'name' ), 'store_url' => fn() => home_url(), ); return isset( $map[ $field ] ) ? (string) $map[ $field ]() : ''; } private function user_meta( array $context, string $key ) : string { $value = get_user_meta( absint( $context['user_id'] ?? 0 ), $key, true ); return is_scalar( $value ) ? (string) $value : ''; } private function user_name( array $context ) : string { $user = get_userdata( absint( $context['user_id'] ?? 0 ) ); return $user ? (string) $user->display_name : ''; } private function user_email( array $context ) : string { $user = get_userdata( absint( $context['user_id'] ?? 0 ) ); return $user ? (string) $user->user_email : ''; } }