Plugin Directory

Changeset 3462451


Ignore:
Timestamp:
02/16/2026 11:43:16 AM (7 weeks ago)
Author:
easypayment
Message:

tags/1.0.8

Location:
payment-gateway-for-authorize-net-for-woocommerce
Files:
61 added
6 edited

Legend:

Unmodified
Added
Removed
  • payment-gateway-for-authorize-net-for-woocommerce/trunk/includes/class-api-handler.php

    r3440293 r3462451  
    258258            ]);
    259259            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    260             return throw new Exception($message);
     260            throw new Exception($message);
    261261        }
    262262        if (isset($response['transactionResponse']['responseCode']) && (string) $response['transactionResponse']['responseCode'] !== '1') {
     
    313313            self::log(__METHOD__, 'Transaction failed', [
    314314                'response_code' => $response['transactionResponse']['responseCode'],
    315                 'response_code' => (string) $response['transactionResponse']['responseCode'],
    316315                'error_code' => $error_code,
    317316                'error_message' => $message,
     
    432431            self::log(__METHOD__, $error_msg, ['order_id' => $order->get_id()]);
    433432            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    434             return throw new Exception($error_msg);
     433            throw new Exception($error_msg);
    435434        }
    436435        $endpoint = self::get_api_endpoint();
     
    518517        if (!$trans_id) {
    519518            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    520             return throw new Exception('easyauthnet_no_trans_id', __('Missing transaction ID.', 'payment-gateway-for-authorize-net-for-woocommerce'));
     519            throw new Exception('easyauthnet_no_trans_id', __('Missing transaction ID.', 'payment-gateway-for-authorize-net-for-woocommerce'));
    521520        }
    522521        self::log(__METHOD__, 'Checking whether to refund or void', ['order_id' => $order->get_id(), 'transaction_id' => $trans_id, 'amount' => $amount, 'reason' => $reason]);
     
    537536        if (!$order) {
    538537            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    539             return throw new Exception(__('Invalid order.', 'payment-gateway-for-authorize-net-for-woocommerce'));
     538            throw new Exception(__('Invalid order.', 'payment-gateway-for-authorize-net-for-woocommerce'));
    540539        }
    541540        self::log(__METHOD__, 'Processing payment refund/void', ['order_id' => $order->get_id(), 'amount' => $amount, 'reason' => $reason]);
     
    557556        if (!$trans_id) {
    558557            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    559             return throw new Exception(__('Missing transaction ID.', 'payment-gateway-for-authorize-net-for-woocommerce'));
     558            throw new Exception(__('Missing transaction ID.', 'payment-gateway-for-authorize-net-for-woocommerce'));
    560559        }
    561560        $endpoint = self::get_api_endpoint();
     
    951950            self::log(__METHOD__, $error, ['has_customer_profile' => (bool) $customer_profile_id, 'has_payment_profile' => (bool) $payment_profile_id]);
    952951            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    953             return throw new Exception($error);
     952            throw new Exception($error);
    954953        }
    955954        $request = [
     
    11331132            self::log(__METHOD__, $error);
    11341133            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    1135             return throw new Exception($error);
     1134            throw new Exception($error);
    11361135        }
    11371136        $customer_profile_id = get_user_meta($user_id, self::$customer_profile_id, true);
     
    11561155        self::log(__METHOD__, $error, ['response' => $result]);
    11571156        // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    1158         return throw new Exception($error);
     1157        throw new Exception($error);
    11591158    }
    11601159
     
    12071206            self::log(__METHOD__, $error, ['user_id' => $user_id]);
    12081207            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    1209             return throw new Exception($error);
     1208            throw new Exception($error);
    12101209        }
    12111210        $customer_profile_id = get_user_meta($user_id, self::$customer_profile_id, true);
     
    13651364            self::log(__METHOD__, 'Capture failed', ['response_code' => $response['transactionResponse']['responseCode'] ?? 'none', 'error' => $error]);
    13661365            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    1367             return throw new Exception($error);
     1366            throw new Exception($error);
    13681367        }
    13691368        self::log(__METHOD__, 'Successfully captured transaction', ['transaction_id' => $response['transactionResponse']['transId'] ?? $transaction_id, 'auth_code' => $response['transactionResponse']['authCode'] ?? '']);
  • payment-gateway-for-authorize-net-for-woocommerce/trunk/includes/class-easy-payment-authorizenet-echeck-gateway.php

    r3440293 r3462451  
    306306        if (is_checkout() || is_checkout_pay_page() || is_account_page()) {
    307307            $merchant_data = EASYAUTHNET_AuthorizeNet_API_Handler::fetch_merchant_details(
    308                     $this->api_login_id,
    309                     $this->transaction_key,
    310                     $this->signature_key,
    311                     $this->environment !== 'live'
     308                            $this->api_login_id,
     309                            $this->transaction_key,
     310                            $this->signature_key,
     311                            $this->environment !== 'live'
    312312            );
    313313            if (!is_wp_error($merchant_data) && isset($merchant_data['publicClientKey'])) {
     
    448448    }
    449449
     450    protected function is_store_api_request(): bool {
     451        if (defined('REST_REQUEST') && REST_REQUEST) {
     452            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
     453            $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
     454            return ($uri && strpos($uri, '/wc/store/') !== false);
     455        }
     456        return false;
     457    }
     458
     459    protected function maybe_mark_order_failed(WC_Order $order, string $message): void {
     460        if ($order->has_status(['processing', 'completed', 'cancelled', 'refunded'])) {
     461            return;
     462        }
     463        $note = sprintf(
     464                __('Authorize.Net payment failed: %s', 'payment-gateway-for-authorize-net-for-woocommerce'),
     465                wp_strip_all_tags($message)
     466        );
     467        if (!$order->has_status('failed')) {
     468            $order->update_status('failed', $note);
     469        } else {
     470            $order->add_order_note($note);
     471        }
     472        $order->save();
     473    }
     474
     475    protected function fail_with_error(WC_Order $order, $error) {
     476        $message = is_wp_error($error) ? (string) $error->get_error_message() : (string) $error;
     477        $message = wp_strip_all_tags($message);
     478        $this->maybe_mark_order_failed($order, $message);
     479        if ($this->is_store_api_request()) {
     480            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
     481            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', $message, 400);
     482        }
     483        wc_add_notice($message, 'error');
     484        return ['result' => 'failure'];
     485    }
     486
     487    protected function handle_payment_exception(WC_Order $order, Exception $e) {
     488        $message = wp_strip_all_tags((string) $e->getMessage());
     489        $this->maybe_mark_order_failed($order, $message);
     490        if ($this->is_store_api_request()) {
     491            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
     492            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', $message, 400);
     493        }
     494        wc_add_notice($message, 'error');
     495        return ['result' => 'failure'];
     496    }
     497
    450498    public function process_payment($order_id) {
    451499        $order = wc_get_order($order_id);
     
    454502        }
    455503
    456         // eCheck (ACH) is available for USD only.
    457         $allowed_currency = strtoupper((string) apply_filters('easyauthnet_authorizenet_echeck_allowed_currency', 'USD'));
    458         if (strtoupper((string) $order->get_currency()) !== $allowed_currency) {
    459             wc_add_notice(
     504        try {
     505            // eCheck (ACH) is available for USD only.
     506            $allowed_currency = strtoupper((string) apply_filters('easyauthnet_authorizenet_echeck_allowed_currency', 'USD'));
     507            if (strtoupper((string) $order->get_currency()) !== $allowed_currency) {
     508                return $this->fail_with_error(
     509                    $order,
    460510                    sprintf(
    461                             // translators: %s is a currency code like USD.
    462                             __('eCheck (ACH) is available for %s only.', 'payment-gateway-for-authorize-net-for-woocommerce'),
    463                             esc_html($allowed_currency)
    464                     ),
    465                     'error'
    466             );
    467             return ['result' => 'failure'];
    468         }
    469 
    470         // phpcs:ignore WordPress.Security.NonceVerification.Missing
    471         $token = isset($_POST['easyauthnet_authorizenet_echeck_token']) ? sanitize_text_field(wp_unslash($_POST['easyauthnet_authorizenet_echeck_token'])) : '';
    472         if (preg_match('/^"(.+)"$/', $token, $m)) {
    473             $token = $m[1];
    474         }
    475 
    476         // phpcs:ignore WordPress.Security.NonceVerification.Missing
    477         $posted_descriptor = isset($_POST['easyauthnet_authorizenet_echeck_descriptor']) ? sanitize_text_field(wp_unslash($_POST['easyauthnet_authorizenet_echeck_descriptor'])) : '';
    478         if (preg_match('/^"(.+)"$/', $posted_descriptor, $m2)) {
    479             $posted_descriptor = $m2[1];
    480         }
    481 
    482         if (empty($token)) {
    483             wc_add_notice(__('Payment error: unable to tokenize bank details. Please try again.', 'payment-gateway-for-authorize-net-for-woocommerce'), 'error');
    484             return ['result' => 'failure'];
    485         }
    486 
    487         EASYAUTHNET_AuthorizeNet_API_Handler::init();
    488 
    489         // Accept.js should return COMMON.ACCEPT.INAPP.CHECK for eCheck tokens.
    490         // However, some merchant setups can return COMMON.ACCEPT.INAPP.PAYMENT even for bank tokenization.
    491         // To avoid Authorize.Net error 153 (Unable to decrypt data), we forward the descriptor returned by Accept.js
    492         // after validating it against a strict allowlist.
    493         $allowed_descriptors = [
    494             'COMMON.ACCEPT.INAPP.CHECK',
    495             'COMMON.ACCEPT.INAPP.PAYMENT',
    496         ];
    497         $descriptor = 'COMMON.ACCEPT.INAPP.CHECK';
    498         if ($posted_descriptor && in_array($posted_descriptor, $allowed_descriptors, true)) {
    499             $descriptor = $posted_descriptor;
    500         }
    501 
    502         $opaque_token = [
    503             'dataDescriptor' => $descriptor,
    504             'dataValue' => $token,
    505         ];
    506 
    507         $this->log('Processing eCheck payment', [
    508             'order_id' => $order->get_id(),
    509             'total' => $order->get_total(),
    510             'environment' => $this->environment,
    511         ]);
    512 
    513         $response = EASYAUTHNET_AuthorizeNet_API_Handler::charge_transaction($order, $opaque_token, [
    514             'transaction_type' => $this->echeck_transaction_type,
    515             'statement_descriptor' => $this->statement_descriptor,
    516         ]);
    517         if (is_wp_error($response)) {
    518             $this->log('eCheck charge failed', [
    519                 'error_code' => $response->get_error_code(),
    520                 'error_message' => $response->get_error_message(),
     511                        /* translators: %s is a currency code like USD. */
     512                        __('eCheck (ACH) is available for %s only.', 'payment-gateway-for-authorize-net-for-woocommerce'),
     513                        esc_html($allowed_currency)
     514                    )
     515                );
     516
     517            }
     518
     519            // phpcs:ignore WordPress.Security.NonceVerification.Missing
     520            $token = isset($_POST['easyauthnet_authorizenet_echeck_token']) ? sanitize_text_field(wp_unslash($_POST['easyauthnet_authorizenet_echeck_token'])) : '';
     521            if (preg_match('/^"(.+)"$/', $token, $m)) {
     522                $token = $m[1];
     523            }
     524
     525            // phpcs:ignore WordPress.Security.NonceVerification.Missing
     526            $posted_descriptor = isset($_POST['easyauthnet_authorizenet_echeck_descriptor']) ? sanitize_text_field(wp_unslash($_POST['easyauthnet_authorizenet_echeck_descriptor'])) : '';
     527            if (preg_match('/^"(.+)"$/', $posted_descriptor, $m2)) {
     528                $posted_descriptor = $m2[1];
     529            }
     530
     531            if (empty($token)) {
     532                return $this->fail_with_error($order, __('Payment error: unable to tokenize bank details. Please try again.', 'payment-gateway-for-authorize-net-for-woocommerce'));
     533            }
     534
     535            EASYAUTHNET_AuthorizeNet_API_Handler::init();
     536
     537            // Accept.js should return COMMON.ACCEPT.INAPP.CHECK for eCheck tokens.
     538            // However, some merchant setups can return COMMON.ACCEPT.INAPP.PAYMENT even for bank tokenization.
     539            // To avoid Authorize.Net error 153 (Unable to decrypt data), we forward the descriptor returned by Accept.js
     540            // after validating it against a strict allowlist.
     541            $allowed_descriptors = [
     542                'COMMON.ACCEPT.INAPP.CHECK',
     543                'COMMON.ACCEPT.INAPP.PAYMENT',
     544            ];
     545            $descriptor = 'COMMON.ACCEPT.INAPP.CHECK';
     546            if ($posted_descriptor && in_array($posted_descriptor, $allowed_descriptors, true)) {
     547                $descriptor = $posted_descriptor;
     548            }
     549
     550            $opaque_token = [
     551                'dataDescriptor' => $descriptor,
     552                'dataValue' => $token,
     553            ];
     554
     555            $this->log('Processing eCheck payment', [
     556                'order_id' => $order->get_id(),
     557                'total' => $order->get_total(),
     558                'environment' => $this->environment,
    521559            ]);
    522             wc_add_notice($response->get_error_message(), 'error');
    523             return ['result' => 'failure'];
    524         }
    525 
    526         $transaction_id = $response['transaction_id'] ?? '';
    527         if (!empty($transaction_id)) {
    528             $order->update_meta_data('_easyauthnet_authorizenet_echeck_transaction_id', $transaction_id);
    529             $order->save();
    530         }
    531 
    532         // For eCheck, settlement can be delayed. Keep it on the configured status (default: on-hold).
    533         if ($order->has_status(['pending', 'failed'])) {
    534             $status = str_replace('wc-', '', (string) $this->echeck_order_status);
    535             if ($status === '') {
    536                 $status = 'on-hold';
    537             }
    538             $order->update_status($status, __('eCheck payment initiated. Awaiting settlement/confirmation.', 'payment-gateway-for-authorize-net-for-woocommerce'));
    539         }
    540 
    541         WC()->cart->empty_cart();
    542         return [
    543             'result' => 'success',
    544             'redirect' => $this->get_return_url($order),
    545         ];
     560
     561            $response = EASYAUTHNET_AuthorizeNet_API_Handler::charge_transaction($order, $opaque_token, [
     562                        'transaction_type' => $this->echeck_transaction_type,
     563                        'statement_descriptor' => $this->statement_descriptor,
     564            ]);
     565            if (is_wp_error($response)) {
     566                $this->log('eCheck charge failed', [
     567                    'error_code' => $response->get_error_code(),
     568                    'error_message' => $response->get_error_message(),
     569                ]);
     570                return $this->fail_with_error($order, $response);
     571            }
     572            if (!is_array($response)) {
     573                return $this->fail_with_error($order, __('Transaction failed.', 'payment-gateway-for-authorize-net-for-woocommerce'));
     574            }
     575            $transaction_id = $response['transaction_id'] ?? '';
     576            if (!empty($transaction_id)) {
     577                $order->update_meta_data('_easyauthnet_authorizenet_echeck_transaction_id', $transaction_id);
     578                $order->save();
     579            }
     580            // For eCheck, settlement can be delayed. Keep it on the configured status (default: on-hold).
     581            if ($order->has_status(['pending', 'failed'])) {
     582                $status = str_replace('wc-', '', (string) $this->echeck_order_status);
     583                if ($status === '') {
     584                    $status = 'on-hold';
     585                }
     586                $order->update_status($status, __('eCheck payment initiated. Awaiting settlement/confirmation.', 'payment-gateway-for-authorize-net-for-woocommerce'));
     587            }
     588
     589            if (function_exists('WC') && WC()->cart) {
     590                WC()->cart->empty_cart();
     591            }
     592            return [
     593                'result' => 'success',
     594                'redirect' => $this->get_return_url($order),
     595            ];
     596        } catch (Exception $ex) {
     597            if ($ex instanceof \Automattic\WooCommerce\StoreApi\Exceptions\RouteException) {
     598                throw $ex;
     599            }
     600            return $this->handle_payment_exception($order, $ex);
     601        }
    546602    }
    547603
  • payment-gateway-for-authorize-net-for-woocommerce/trunk/includes/class-easy-payment-authorizenet-gateway.php

    r3440293 r3462451  
    881881    public function process_payment($order_id) {
    882882        $order = wc_get_order($order_id);
     883
     884        if (! $order) {
     885            if ($this->is_store_api_request()) {
     886                throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', __('Invalid order.', 'payment-gateway-for-authorize-net-for-woocommerce'), 400);
     887            }
     888            wc_add_notice(__('Invalid order.', 'payment-gateway-for-authorize-net-for-woocommerce'), 'error');
     889            return ['result' => 'failure'];
     890        }
     891
    883892        try {
    884893            $this->log_start_payment($order);
     
    898907            return $this->process_with_new_card($order);
    899908        } catch (Exception $ex) {
    900             return $this->handle_payment_exception($ex);
     909            if ($ex instanceof \Automattic\WooCommerce\StoreApi\Exceptions\RouteException) {
     910                throw $ex;
     911            }
     912            return $this->handle_payment_exception($order, $ex);
    901913        }
    902914    }
     
    980992        if (is_wp_error($response)) {
    981993            $this->log("Failed to charge saved token", ['error_code' => $response->get_error_code(), 'error_message' => $response->get_error_message()]);
    982             return $this->fail_with_error($response);
     994            return $this->fail_with_error($order, $response);
    983995        }
    984996        $this->log("Successfully charged saved token", ['transaction_id' => $response['transaction_id'] ?? 'N/A', 'response_code' => $response['response_code'] ?? null, 'auth_code' => $response['auth_code'] ?? null]);
     
    10671079        $is_subscription = $this->is_subscription_order($order);
    10681080
    1069         // Default logic: user opted in OR it's a subscription
     1081        // Default logic: save only when explicitly required.
     1082        // - User opted in via the "Save payment method" checkbox, OR
     1083        // - Subscription order (requires a reusable token for renewals).
    10701084        $should_save = $user_opted_in || $is_subscription;
    10711085
     
    10771091         * @param EASYAUTHNET_AuthorizeNet_Gateway  $gateway     Gateway instance.
    10781092         */
    1079         $should_save = (bool) apply_filters(
     1093        $filtered_should_save = (bool) apply_filters(
    10801094                        'easyauthnet_authorizenet_should_save_card',
    10811095                        $should_save,
     
    10841098                );
    10851099
     1100        // Enterprise-safe guardrail:
     1101        // Never save a card for a non-subscription order unless the customer opted in.
     1102        // This avoids consuming the one-time opaque token on CIM operations for typical one-time checkouts.
     1103        if (!$user_opted_in && !$is_subscription) {
     1104            $should_save = false;
     1105        } else {
     1106            $should_save = $filtered_should_save;
     1107        }
     1108
    10861109        // Log debug values safely
    10871110        $this->log('Checking if should save card', [
     
    10891112            'user_opted_in' => $user_opted_in,
    10901113            'is_subscription' => $is_subscription,
     1114            'filtered_should_save' => $filtered_should_save,
    10911115            'final_should_save' => $should_save,
    10921116        ]);
     
    11031127        if (is_wp_error($token)) {
    11041128            $error_code = $token->get_error_code();
     1129            $error_data = $token->get_error_data();
     1130            $anet_code  = '';
     1131            if (is_array($error_data) && !empty($error_data['error_code'])) {
     1132                $anet_code = (string) $error_data['error_code'];
     1133            }
    11051134            $this->log("Failed to save payment token", ['error_code' => $error_code, 'error_message' => $token->get_error_message()]);
     1135
     1136            // Graceful fallback: intermittent Authorize.Net E00114 (Invalid OTS Token).
     1137            // In this scenario, the CIM save fails but we can still charge the order using opaque data.
     1138            // NOTE: For subscriptions, this will successfully charge the initial order but the card will NOT be saved
     1139            // for renewals; we add an order note so the merchant can follow up.
     1140            if ($anet_code === 'E00114' || stripos((string) $token->get_error_message(), 'Invalid OTS Token') !== false) {
     1141                $this->log('CIM save failed with Invalid OTS Token - falling back to one-time charge using opaque data', [
     1142                    'order_id' => $order->get_id(),
     1143                    'wp_error_code' => $error_code,
     1144                    'anet_error_code' => $anet_code ?: 'E00114',
     1145                    'is_subscription' => $this->is_subscription_order($order),
     1146                ]);
     1147
     1148                if ($order instanceof WC_Order) {
     1149                    $order->add_order_note(
     1150                        __('Authorize.Net: Card could not be saved to CIM (Invalid OTS Token). Order was charged as a one-time payment. Customer may need to re-add a saved payment method for future renewals.', 'payment-gateway-for-authorize-net-for-woocommerce')
     1151                    );
     1152                }
     1153
     1154                return $this->charge_without_saving($order, $opaque_data['gateway_token']);
     1155            }
    11061156
    11071157            if ($error_code === 'easyauthnet_duplicate_payment_profile') {
     
    11121162                    'card_type' => $opaque_data['card_type'] ?? 'none',
    11131163                ]);
     1164
     1165                // If Authorize.Net returned the duplicate payment profile ID, charge via CIM profile.
     1166                // IMPORTANT: do NOT attempt to charge with opaque data here because the token may
     1167                // have already been consumed by the CIM validation step (leading to E00114).
     1168                $dup_customer_profile_id = is_array($error_data) ? ($error_data['customer_profile_id'] ?? '') : '';
     1169                $dup_payment_profile_id  = is_array($error_data) ? ($error_data['customer_payment_profile_id'] ?? '') : '';
     1170
     1171                if (!empty($dup_payment_profile_id)) {
     1172                    $user_id = (int) $order->get_user_id();
     1173
     1174                    // Try to locate an existing Woo token that matches this CIM payment profile.
     1175                    $existing_by_profile = null;
     1176                    if ($user_id > 0) {
     1177                        $tokens = WC_Payment_Tokens::get_customer_tokens($user_id, $this->id);
     1178                        foreach ($tokens as $t) {
     1179                            if ($t instanceof WC_Payment_Token && (string) $t->get_token() === (string) $dup_payment_profile_id) {
     1180                                $existing_by_profile = $t;
     1181                                break;
     1182                            }
     1183                        }
     1184                    }
     1185
     1186                    $token_to_use = $existing_by_profile;
     1187
     1188                    // If not found, create a Woo token so renewals/manual reuse can work.
     1189                    if (!$token_to_use && $user_id > 0) {
     1190                        $token_to_use = new WC_Payment_Token_CC();
     1191                        $token_to_use->set_token($dup_payment_profile_id);
     1192                        $token_to_use->set_gateway_id($this->id);
     1193                        $token_to_use->set_card_type(strtolower($opaque_data['card_type'] ?? ''));
     1194                        $token_to_use->set_last4($opaque_data['last4'] ?? '');
     1195
     1196                        $expiry = $opaque_data['expiry'] ?? '';
     1197                        if (preg_match('/^(\d{2})\/(\d{2})$/', $expiry, $m)) {
     1198                            $token_to_use->set_expiry_month($m[1]);
     1199                            $token_to_use->set_expiry_year('20' . $m[2]);
     1200                        }
     1201
     1202                        $token_to_use->set_user_id($user_id);
     1203                        $token_to_use->set_default(true);
     1204                        if (!empty($dup_customer_profile_id)) {
     1205                            $token_to_use->add_meta_data('customer_profile_id', $dup_customer_profile_id, true);
     1206                        }
     1207                        $token_to_use->add_meta_data('payment_profile_id', $dup_payment_profile_id, true);
     1208                        $token_to_use->add_meta_data('created_via_order_id', $order->get_id(), true);
     1209                        $token_to_use->save();
     1210
     1211                        $this->log('Created Woo token for duplicate CIM payment profile', [
     1212                            'token_id' => $token_to_use->get_id(),
     1213                            'payment_profile_id' => $this->mask_sensitive_data($dup_payment_profile_id),
     1214                        ]);
     1215                    }
     1216
     1217                    if ($token_to_use) {
     1218                        if ($this->is_subscription_order($order)) {
     1219                            EASYAUTHNET_Subscription_Helper::assign_token_to_order_and_subscriptions($order, $token_to_use);
     1220                        }
     1221
     1222                        $response = EASYAUTHNET_AuthorizeNet_API_Handler::charge_saved_token($order, $token_to_use);
     1223                        if (!is_wp_error($response)) {
     1224                            $this->log('Successfully charged using duplicate CIM payment profile', [
     1225                                'transaction_id' => $response['transaction_id'] ?? 'N/A',
     1226                            ]);
     1227                            EASYAUTHNET_AuthorizeNet_API_Handler::complete_payment($order, $response['transaction_id']);
     1228                            return $this->success_redirect($order);
     1229                        }
     1230
     1231                        $this->log('Charge using duplicate CIM payment profile failed', [
     1232                            'error_code' => $response->get_error_code(),
     1233                            'error_message' => $response->get_error_message(),
     1234                        ]);
     1235                        // Fall through to existing-token lookup below, then final fallback.
     1236                    }
     1237                }
    11141238
    11151239                // 1) Try to reuse an existing Woo token for the same card (best for subscriptions).
     
    11481272                }
    11491273
    1150                 // 2) Fallback: charge as a one-time payment (do not save).
     1274                // 2) Final fallback: as a last resort, attempt a one-time charge.
     1275                // NOTE: If CIM validation already consumed the token, this may still fail with E00114.
    11511276                return $this->charge_without_saving($order, $opaque_data['gateway_token']);
    11521277            }
    11531278
    1154             return $this->fail_with_error($token);
     1279            return $this->fail_with_error($order, $token);
    11551280        }
    11561281
     
    11671292        if (is_wp_error($response)) {
    11681293            $this->log("Failed to charge after saving token", ['error_code' => $response->get_error_code(), 'error_message' => $response->get_error_message()]);
    1169             return $this->fail_with_error($response);
     1294            return $this->fail_with_error($order, $response);
    11701295        }
    11711296        $this->log("Successfully charged after saving token", ['transaction_id' => $response['transaction_id'] ?? 'N/A', 'response_code' => $response['response_code'] ?? null, 'auth_code' => $response['auth_code'] ?? null]);
     
    12311356        if (is_wp_error($response)) {
    12321357            $this->log("Failed to process one-time payment", ['error_code' => $response->get_error_code(), 'error_message' => $response->get_error_message()]);
    1233             return $this->fail_with_error($response);
     1358            return $this->fail_with_error($order, $response);
    12341359        }
    12351360        $this->log("Successfully processed one-time payment", ['transaction_id' => $response['transaction_id'] ?? 'N/A', 'response_code' => $response['response_code'] ?? null, 'auth_code' => $response['auth_code'] ?? null]);
     
    12381363    }
    12391364
    1240     protected function fail_with_error($error) {
    1241         $this->log("Payment processing failed", ['error_code' => $error->get_error_code(), 'error_message' => $error->get_error_message()]);
    1242         // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at REST output time.
    1243         throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', $error->get_error_message(), 400);
     1365    protected function fail_with_error($order, $error) {
     1366        $message = is_wp_error($error) ? (string) $error->get_error_message() : (string) $error;
     1367        $code    = is_wp_error($error) ? (string) $error->get_error_code() : 'unknown';
     1368        $message = wp_strip_all_tags($message);
     1369        $this->log('Payment processing failed', [
     1370            'order_id' => $order instanceof WC_Order ? $order->get_id() : 0,
     1371            'error_code' => $code,
     1372            'error_message' => $message,
     1373            'is_store_api' => $this->is_store_api_request(),
     1374        ]);
     1375        if ($order instanceof WC_Order) {
     1376            $this->maybe_mark_order_failed($order, $message);
     1377        }
     1378        if ($this->is_store_api_request()) {
     1379            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
     1380            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', $message, 400);
     1381        }
     1382        wc_add_notice($message, 'error');
     1383        return ['result' => 'failure'];
     1384    }
     1385   
     1386    protected function maybe_mark_order_failed(WC_Order $order, $message) {
     1387        if ($order->has_status(['processing', 'completed', 'cancelled', 'refunded'])) {
     1388            return;
     1389        }
     1390        $note = sprintf(
     1391            __('Authorize.Net payment failed: %s', 'payment-gateway-for-authorize-net-for-woocommerce'),
     1392            wp_strip_all_tags((string) $message)
     1393        );
     1394        if (!$order->has_status('failed')) {
     1395            $order->update_status('failed', $note);
     1396        } else {
     1397            $order->add_order_note($note);
     1398        }
     1399        $order->save();
    12441400    }
    12451401
     
    12631419        ];
    12641420    }
    1265 
    1266     protected function handle_payment_exception($e) {
    1267         $this->log("Payment processing exception", ['exception' => get_class($e), 'message' => $e->getMessage(), 'file' => $e->getFile(), 'line' => $e->getLine()]);
    1268         // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at REST output time.
    1269         throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', $e->getMessage(), 400);
     1421   
     1422    protected function is_store_api_request(): bool {
     1423        if (defined('REST_REQUEST') && REST_REQUEST) {
     1424            // Woo Store API endpoints usually include /wc/store/
     1425            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
     1426            $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
     1427            if ($uri && strpos($uri, '/wc/store/') !== false) {
     1428                return true;
     1429            }
     1430        }
     1431        return false;
     1432    }
     1433
     1434    protected function handle_payment_exception($order, $e) {
     1435        $message = $e instanceof Exception ? (string) $e->getMessage() : (string) $e;
     1436        $message = wp_strip_all_tags($message);
     1437        $this->log('Payment processing exception', [
     1438            'order_id' => $order instanceof WC_Order ? $order->get_id() : 0,
     1439            'exception' => is_object($e) ? get_class($e) : 'unknown',
     1440            'message' => $message,
     1441            'is_store_api' => $this->is_store_api_request(),
     1442        ]);
     1443        if ($order instanceof WC_Order) {
     1444            $this->maybe_mark_order_failed($order, $message);
     1445        }
     1446        if ($this->is_store_api_request()) {
     1447            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
     1448            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', $message, 400);
     1449        }
     1450        wc_add_notice($message, 'error');
     1451        return ['result' => 'failure'];
    12701452    }
    12711453
     
    13031485            $this->log("Cannot save token - missing requirements", ['error' => $error]);
    13041486            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
    1305             return throw new Exception($error);
     1487            throw new Exception($error);
    13061488        }
    13071489        $customer_profile_id = get_user_meta($user_id, $this->customer_profile_id, true);
     
    13131495            $customer_profile_id = '';
    13141496        }
     1497        $payment_profile_id_from_create = '';
     1498
    13151499        if (!$customer_profile_id) {
    13161500            $create_result = EASYAUTHNET_AuthorizeNet_API_Handler::create_customer_profile($order, $payment_data['gateway_token']);
     
    13331517                    'customer_profile_id' => $this->mask_sensitive_data($customer_profile_id),
    13341518                ]);
     1519
     1520                // IMPORTANT: createCustomerProfileRequest already includes a payment profile built from opaqueData.
     1521                // If Authorize.Net returns the created paymentProfileId(s), reuse the first one and DO NOT call
     1522                // createCustomerPaymentProfileRequest again (opaqueData tokens are one-time-use).
     1523                if (!empty($create_result['customerPaymentProfileIdList'])) {
     1524                    $list = $create_result['customerPaymentProfileIdList'];
     1525                    $candidate = '';
     1526                    if (is_array($list)) {
     1527                        // Authorize.Net may return { numericString: ["123"] } or a flat array.
     1528                        if (isset($list['numericString'])) {
     1529                            $ns = $list['numericString'];
     1530                            if (is_array($ns)) {
     1531                                $candidate = (string) reset($ns);
     1532                            } else {
     1533                                $candidate = (string) $ns;
     1534                            }
     1535                        } else {
     1536                            $candidate = (string) reset($list);
     1537                        }
     1538                    } else {
     1539                        $candidate = (string) $list;
     1540                    }
     1541                    if ($candidate !== '') {
     1542                        $payment_profile_id_from_create = $candidate;
     1543                        $this->log('Reusing payment profile id returned by createCustomerProfileRequest', [
     1544                            'payment_profile_id' => $this->mask_sensitive_data($payment_profile_id_from_create),
     1545                        ]);
     1546                    }
     1547                }
    13351548            }
    13361549            update_user_meta($user_id, $this->customer_profile_id, $customer_profile_id);
    13371550        }
    1338         // 2) ✅ Create a NEW payment profile (card) under the EXISTING customer profile
    1339         $this->log("Creating new CIM payment profile for saved card", [
    1340             'customer_profile_id' => $this->mask_sensitive_data($customer_profile_id),
    1341         ]);
    1342         $payment_profile_id = EASYAUTHNET_AuthorizeNet_API_Handler::create_customer_payment_profile(
    1343                 $customer_profile_id,
    1344                 $payment_data['gateway_token'],
    1345                 $order
    1346         );
     1551        // 2) Create a NEW payment profile (card) under the EXISTING customer profile.
     1552        // If we JUST created the customer profile and Authorize.Net returned the paymentProfileId, reuse it.
     1553        if (!empty($payment_profile_id_from_create)) {
     1554            $payment_profile_id = $payment_profile_id_from_create;
     1555        } else {
     1556            $this->log("Creating new CIM payment profile for saved card", [
     1557                'customer_profile_id' => $this->mask_sensitive_data($customer_profile_id),
     1558            ]);
     1559            $payment_profile_id = EASYAUTHNET_AuthorizeNet_API_Handler::create_customer_payment_profile(
     1560                    $customer_profile_id,
     1561                    $payment_data['gateway_token'],
     1562                    $order
     1563            );
     1564        }
    13471565        if (is_wp_error($payment_profile_id)) {
    13481566            $this->log("Failed to create CIM payment profile", [
     
    13561574            $this->log("No payment profile ID returned from CIM create", ['error' => $error]);
    13571575            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
    1358             return throw new Exception($error);
     1576            throw new Exception($error);
    13591577        }
    13601578        $token = new WC_Payment_Token_CC();
     
    15201738            $this->log("Capture failed - no transaction ID", ['error' => $error]);
    15211739            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception message is sanitized and escaped at output time.
    1522             return throw new Exception($error);
     1740            throw new Exception($error);
    15231741        }
    15241742        $response = EASYAUTHNET_AuthorizeNet_API_Handler::capture_authorized_transaction($order, $transaction_id);
  • payment-gateway-for-authorize-net-for-woocommerce/trunk/includes/class-easy-payment-authorizenet-googlepay-gateway.php

    r3440293 r3462451  
    489489        return true;
    490490    }
     491   
     492    protected function is_store_api_request(): bool {
     493        if (defined('REST_REQUEST') && REST_REQUEST) {
     494            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
     495            $uri = isset($_SERVER['REQUEST_URI']) ? (string) $_SERVER['REQUEST_URI'] : '';
     496            return ($uri && strpos($uri, '/wc/store/') !== false);
     497        }
     498        return false;
     499    }
     500
     501    protected function maybe_mark_order_failed(WC_Order $order, string $message): void {
     502        if ($order->has_status(['processing', 'completed', 'cancelled', 'refunded'])) {
     503            return;
     504        }
     505        $note = sprintf(
     506            __('Authorize.Net payment failed: %s', 'payment-gateway-for-authorize-net-for-woocommerce'),
     507            wp_strip_all_tags($message)
     508        );
     509        if (!$order->has_status('failed')) {
     510            $order->update_status('failed', $note);
     511        } else {
     512            $order->add_order_note($note);
     513        }
     514        $order->save();
     515    }
     516   
     517    protected function fail_with_error(WC_Order $order, $error) {
     518        $message = is_wp_error($error) ? (string) $error->get_error_message() : (string) $error;
     519        $message = wp_strip_all_tags($message);
     520        $this->maybe_mark_order_failed($order, $message);
     521        if ($this->is_store_api_request()) {
     522            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
     523            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', $message, 400);
     524        }
     525        wc_add_notice($message, 'error');
     526        return ['result' => 'failure'];
     527    }
     528
     529    protected function handle_payment_exception(WC_Order $order, Exception $e) {
     530        $message = wp_strip_all_tags((string) $e->getMessage());
     531        $this->maybe_mark_order_failed($order, $message);
     532        if ($this->is_store_api_request()) {
     533            // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
     534            throw new \Automattic\WooCommerce\StoreApi\Exceptions\RouteException('AuthorizeNet_API', $message, 400);
     535        }
     536        wc_add_notice($message, 'error');
     537        return ['result' => 'failure'];
     538    }
     539
    491540
    492541    public function process_payment($order_id) {
     
    534583
    535584            if (is_wp_error($response)) {
    536                 throw new Exception($response->get_error_message() ?: __('Google Pay payment failed.', 'payment-gateway-for-authorize-net-for-woocommerce'));
     585                return $this->fail_with_error($order, $response);
    537586            }
    538587
     
    563612            if (!$ok) {
    564613                $message = !empty($response['message']) ? $response['message'] : __('Transaction failed.', 'payment-gateway-for-authorize-net-for-woocommerce');
    565                 throw new Exception($message);
     614                // Use the unified handler so order becomes Failed + note,
     615                // and Blocks gets RouteException while Classic gets wc notice.
     616                return $this->fail_with_error($order, $message);
    566617            }
    567618
     
    578629            ];
    579630        } catch (Exception $e) {
    580             wc_add_notice($e->getMessage(), 'error');
    581             return ['result' => 'failure'];
     631            if ($e instanceof \Automattic\WooCommerce\StoreApi\Exceptions\RouteException) {
     632                throw $e;
     633            }
     634            return $this->handle_payment_exception($order, $e);
    582635        }
    583636    }
  • payment-gateway-for-authorize-net-for-woocommerce/trunk/payment-gateway-for-authorizenet-for-woocommerce.php

    r3440293 r3462451  
    77 * Author: easypayment
    88 * Author URI: https://profiles.wordpress.org/easypayment/
    9  * Version: 1.0.7
     9 * Version: 1.0.8
    1010 * Requires at least: 5.6
    11  * Tested up to: 6.9
     11 * Tested up to: 6.9.1
    1212 * Requires PHP: 7.4
    1313 * Text Domain: payment-gateway-for-authorize-net-for-woocommerce
    1414 * Domain Path: /languages/
    1515 * WC requires at least: 6.0
    16  * WC tested up to: 10.4.3
     16 * WC tested up to: 10.5.1
    1717 * Requires Plugins: woocommerce
    1818 * License: GPLv2 or later
     
    2323
    2424if (!defined('EASYAUTHNET_AUTHORIZENET_VERSION')) {
    25     define('EASYAUTHNET_AUTHORIZENET_VERSION', '1.0.7');
     25    define('EASYAUTHNET_AUTHORIZENET_VERSION', '1.0.8');
    2626}
    2727define('EASYAUTHNET_AUTHORIZENET_PLUGIN_FILE', __FILE__);
  • payment-gateway-for-authorize-net-for-woocommerce/trunk/readme.txt

    r3440293 r3462451  
    55Tested up to: 6.9
    66Requires PHP: 7.4 
    7 Stable tag: 1.0.7
     7Stable tag: 1.0.8
    88License: GPLv2 or later 
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html 
     
    118118== Changelog ==
    119119
     120= 1.0.8 =
     121* Fixed – Intermittent checkout failures (E00114 Invalid OTS Token) and improved CIM card-saving flow stability.
     122
    120123= 1.0.7 =
    121124* Added – Support for eCheck (ACH) payments.
Note: See TracChangeset for help on using the changeset viewer.