swissChili | f0cbdc3 | 2023-01-05 17:21:38 -0500 | [diff] [blame] | 1 | <?php |
| 2 | if ( ! defined( 'ABSPATH' ) ) { |
| 3 | exit; |
| 4 | } |
| 5 | |
| 6 | /** |
| 7 | * Handles and process orders from asyncronous flows. |
| 8 | * |
| 9 | * @since 4.0.0 |
| 10 | */ |
| 11 | class WC_Stripe_Order_Handler extends WC_Stripe_Payment_Gateway { |
| 12 | private static $_this; |
| 13 | |
| 14 | /** |
| 15 | * Constructor. |
| 16 | * |
| 17 | * @since 4.0.0 |
| 18 | * @version 4.0.0 |
| 19 | */ |
| 20 | public function __construct() { |
| 21 | self::$_this = $this; |
| 22 | |
| 23 | add_action( 'wp', [ $this, 'maybe_process_redirect_order' ] ); |
| 24 | add_action( 'woocommerce_order_status_processing', [ $this, 'capture_payment' ] ); |
| 25 | add_action( 'woocommerce_order_status_completed', [ $this, 'capture_payment' ] ); |
| 26 | add_action( 'woocommerce_order_status_cancelled', [ $this, 'cancel_payment' ] ); |
| 27 | add_action( 'woocommerce_order_status_refunded', [ $this, 'cancel_payment' ] ); |
| 28 | add_filter( 'woocommerce_tracks_event_properties', [ $this, 'woocommerce_tracks_event_properties' ], 10, 2 ); |
| 29 | } |
| 30 | |
| 31 | /** |
| 32 | * Public access to instance object. |
| 33 | * |
| 34 | * @since 4.0.0 |
| 35 | * @version 4.0.0 |
| 36 | */ |
| 37 | public static function get_instance() { |
| 38 | return self::$_this; |
| 39 | } |
| 40 | |
| 41 | /** |
| 42 | * Processes payments. |
| 43 | * Note at this time the original source has already been |
| 44 | * saved to a customer card (if applicable) from process_payment. |
| 45 | * |
| 46 | * @since 4.0.0 |
| 47 | * @since 4.1.8 Add $previous_error parameter. |
| 48 | * @param int $order_id |
| 49 | * @param bool $retry |
| 50 | * @param mix $previous_error Any error message from previous request. |
| 51 | */ |
| 52 | public function process_redirect_payment( $order_id, $retry = true, $previous_error = false ) { |
| 53 | try { |
| 54 | $source = isset( $_GET['source'] ) ? wc_clean( wp_unslash( $_GET['source'] ) ) : ''; |
| 55 | |
| 56 | if ( empty( $source ) ) { |
| 57 | return; |
| 58 | } |
| 59 | |
| 60 | if ( empty( $order_id ) ) { |
| 61 | return; |
| 62 | } |
| 63 | |
| 64 | $order = wc_get_order( $order_id ); |
| 65 | |
| 66 | if ( ! is_object( $order ) ) { |
| 67 | return; |
| 68 | } |
| 69 | |
| 70 | if ( $order->has_status( [ 'processing', 'completed', 'on-hold' ] ) ) { |
| 71 | return; |
| 72 | } |
| 73 | |
| 74 | // Result from Stripe API request. |
| 75 | $response = null; |
| 76 | |
| 77 | // This will throw exception if not valid. |
| 78 | $this->validate_minimum_order_amount( $order ); |
| 79 | |
| 80 | WC_Stripe_Logger::log( "Info: (Redirect) Begin processing payment for order $order_id for the amount of {$order->get_total()}" ); |
| 81 | |
| 82 | /** |
| 83 | * First check if the source is chargeable at this time. If not, |
| 84 | * webhook will take care of it later. |
| 85 | */ |
| 86 | $source_info = WC_Stripe_API::retrieve( 'sources/' . $source ); |
| 87 | |
| 88 | if ( ! empty( $source_info->error ) ) { |
| 89 | throw new WC_Stripe_Exception( print_r( $source_info, true ), $source_info->error->message ); |
| 90 | } |
| 91 | |
| 92 | if ( 'failed' === $source_info->status || 'canceled' === $source_info->status ) { |
| 93 | throw new WC_Stripe_Exception( print_r( $source_info, true ), __( 'Unable to process this payment, please try again or use alternative method.', 'woocommerce-gateway-stripe' ) ); |
| 94 | } |
| 95 | |
| 96 | // If already consumed, then ignore request. |
| 97 | if ( 'consumed' === $source_info->status ) { |
| 98 | return; |
| 99 | } |
| 100 | |
| 101 | // If not chargeable, then ignore request. |
| 102 | if ( 'chargeable' !== $source_info->status ) { |
| 103 | return; |
| 104 | } |
| 105 | |
| 106 | // Prep source object. |
| 107 | $prepared_source = $this->prepare_order_source( $order ); |
| 108 | $prepared_source->status = 'chargeable'; |
| 109 | |
| 110 | /* |
| 111 | * If we're doing a retry and source is chargeable, we need to pass |
| 112 | * a different idempotency key and retry for success. |
| 113 | */ |
| 114 | if ( $this->need_update_idempotency_key( $prepared_source, $previous_error ) ) { |
| 115 | add_filter( 'wc_stripe_idempotency_key', [ $this, 'change_idempotency_key' ], 10, 2 ); |
| 116 | } |
| 117 | |
| 118 | // Make the request. |
| 119 | $response = WC_Stripe_API::request( $this->generate_payment_request( $order, $prepared_source ), 'charges', 'POST', true ); |
| 120 | $headers = $response['headers']; |
| 121 | $response = $response['body']; |
| 122 | |
| 123 | if ( ! empty( $response->error ) ) { |
| 124 | // Customer param wrong? The user may have been deleted on stripe's end. Remove customer_id. Can be retried without. |
| 125 | if ( $this->is_no_such_customer_error( $response->error ) ) { |
| 126 | delete_user_option( $order->get_customer_id(), '_stripe_customer_id' ); |
| 127 | $order->delete_meta_data( '_stripe_customer_id' ); |
| 128 | $order->save(); |
| 129 | } |
| 130 | |
| 131 | if ( $this->is_no_such_token_error( $response->error ) && $prepared_source->token_id ) { |
| 132 | // Source param wrong? The CARD may have been deleted on stripe's end. Remove token and show message. |
| 133 | $wc_token = WC_Payment_Tokens::get( $prepared_source->token_id ); |
| 134 | $wc_token->delete(); |
| 135 | $localized_message = __( 'This card is no longer available and has been removed.', 'woocommerce-gateway-stripe' ); |
| 136 | $order->add_order_note( $localized_message ); |
| 137 | throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message ); |
| 138 | } |
| 139 | |
| 140 | // We want to retry. |
| 141 | if ( $this->is_retryable_error( $response->error ) ) { |
| 142 | if ( $retry ) { |
| 143 | // Don't do anymore retries after this. |
| 144 | if ( 5 <= $this->retry_interval ) { |
| 145 | return $this->process_redirect_payment( $order_id, false, $response->error ); |
| 146 | } |
| 147 | |
| 148 | sleep( $this->retry_interval ); |
| 149 | |
| 150 | $this->retry_interval++; |
| 151 | return $this->process_redirect_payment( $order_id, true, $response->error ); |
| 152 | } else { |
| 153 | $localized_message = __( 'Sorry, we are unable to process your payment at this time. Please retry later.', 'woocommerce-gateway-stripe' ); |
| 154 | $order->add_order_note( $localized_message ); |
| 155 | throw new WC_Stripe_Exception( print_r( $response, true ), $localized_message ); |
| 156 | } |
| 157 | } |
| 158 | |
| 159 | $localized_messages = WC_Stripe_Helper::get_localized_messages(); |
| 160 | |
| 161 | if ( 'card_error' === $response->error->type ) { |
| 162 | $message = isset( $localized_messages[ $response->error->code ] ) ? $localized_messages[ $response->error->code ] : $response->error->message; |
| 163 | } else { |
| 164 | $message = isset( $localized_messages[ $response->error->type ] ) ? $localized_messages[ $response->error->type ] : $response->error->message; |
| 165 | } |
| 166 | |
| 167 | throw new WC_Stripe_Exception( print_r( $response, true ), $message ); |
| 168 | } |
| 169 | |
| 170 | // To prevent double processing the order on WC side. |
| 171 | if ( ! $this->is_original_request( $headers ) ) { |
| 172 | return; |
| 173 | } |
| 174 | |
| 175 | do_action( 'wc_gateway_stripe_process_redirect_payment', $response, $order ); |
| 176 | |
| 177 | $this->process_response( $response, $order ); |
| 178 | |
| 179 | } catch ( WC_Stripe_Exception $e ) { |
| 180 | WC_Stripe_Logger::log( 'Error: ' . $e->getMessage() ); |
| 181 | |
| 182 | do_action( 'wc_gateway_stripe_process_redirect_payment_error', $e, $order ); |
| 183 | |
| 184 | /* translators: error message */ |
| 185 | $order->update_status( 'failed', sprintf( __( 'Stripe payment failed: %s', 'woocommerce-gateway-stripe' ), $e->getLocalizedMessage() ) ); |
| 186 | |
| 187 | wc_add_notice( $e->getLocalizedMessage(), 'error' ); |
| 188 | wp_safe_redirect( wc_get_checkout_url() ); |
| 189 | exit; |
| 190 | } |
| 191 | } |
| 192 | |
| 193 | /** |
| 194 | * Processes the orders that are redirected. |
| 195 | * |
| 196 | * @since 4.0.0 |
| 197 | * @version 4.0.0 |
| 198 | */ |
| 199 | public function maybe_process_redirect_order() { |
| 200 | if ( ! is_order_received_page() || empty( $_GET['client_secret'] ) || empty( $_GET['source'] ) ) { |
| 201 | return; |
| 202 | } |
| 203 | |
| 204 | $order_id = isset( $_GET['order_id'] ) ? wc_clean( wp_unslash( $_GET['order_id'] ) ) : ''; |
| 205 | |
| 206 | $this->process_redirect_payment( $order_id ); |
| 207 | } |
| 208 | |
| 209 | /** |
| 210 | * Capture payment when the order is changed from on-hold to complete or processing. |
| 211 | * |
| 212 | * @since 3.1.0 |
| 213 | * @version 4.0.0 |
| 214 | * @param int $order_id |
| 215 | * @return stdClass|void Result of payment capture. |
| 216 | */ |
| 217 | public function capture_payment( $order_id ) { |
| 218 | $order = wc_get_order( $order_id ); |
| 219 | |
| 220 | if ( 'stripe' === $order->get_payment_method() ) { |
| 221 | $charge = $order->get_transaction_id(); |
| 222 | $captured = $order->get_meta( '_stripe_charge_captured', true ); |
| 223 | $is_stripe_captured = false; |
| 224 | |
| 225 | if ( $charge && 'no' === $captured ) { |
| 226 | $order_total = $order->get_total(); |
| 227 | |
| 228 | if ( 0 < $order->get_total_refunded() ) { |
| 229 | $order_total = $order_total - $order->get_total_refunded(); |
| 230 | } |
| 231 | |
| 232 | $intent = $this->get_intent_from_order( $order ); |
| 233 | if ( $intent ) { |
| 234 | // If the order has a Payment Intent, then the Intent itself must be captured, not the Charge |
| 235 | if ( ! empty( $intent->error ) ) { |
| 236 | /* translators: error message */ |
| 237 | $order->add_order_note( sprintf( __( 'Unable to capture charge! %s', 'woocommerce-gateway-stripe' ), $intent->error->message ) ); |
| 238 | } elseif ( 'requires_capture' === $intent->status ) { |
| 239 | $level3_data = $this->get_level3_data_from_order( $order ); |
| 240 | $result = WC_Stripe_API::request_with_level3_data( |
| 241 | [ |
| 242 | 'amount' => WC_Stripe_Helper::get_stripe_amount( $order_total ), |
| 243 | 'expand[]' => 'charges.data.balance_transaction', |
| 244 | ], |
| 245 | 'payment_intents/' . $intent->id . '/capture', |
| 246 | $level3_data, |
| 247 | $order |
| 248 | ); |
| 249 | |
| 250 | if ( ! empty( $result->error ) ) { |
| 251 | /* translators: error message */ |
| 252 | $order->update_status( 'failed', sprintf( __( 'Unable to capture charge! %s', 'woocommerce-gateway-stripe' ), $result->error->message ) ); |
| 253 | } else { |
| 254 | $is_stripe_captured = true; |
| 255 | $result = end( $result->charges->data ); |
| 256 | } |
| 257 | } elseif ( 'succeeded' === $intent->status ) { |
| 258 | $is_stripe_captured = true; |
| 259 | } |
| 260 | } else { |
| 261 | // The order doesn't have a Payment Intent, fall back to capturing the Charge directly |
| 262 | |
| 263 | // First retrieve charge to see if it has been captured. |
| 264 | $result = WC_Stripe_API::retrieve( 'charges/' . $charge ); |
| 265 | |
| 266 | if ( ! empty( $result->error ) ) { |
| 267 | /* translators: error message */ |
| 268 | $order->add_order_note( sprintf( __( 'Unable to capture charge! %s', 'woocommerce-gateway-stripe' ), $result->error->message ) ); |
| 269 | } elseif ( false === $result->captured ) { |
| 270 | $level3_data = $this->get_level3_data_from_order( $order ); |
| 271 | $result = WC_Stripe_API::request_with_level3_data( |
| 272 | [ |
| 273 | 'amount' => WC_Stripe_Helper::get_stripe_amount( $order_total ), |
| 274 | 'expand[]' => 'balance_transaction', |
| 275 | ], |
| 276 | 'charges/' . $charge . '/capture', |
| 277 | $level3_data, |
| 278 | $order |
| 279 | ); |
| 280 | |
| 281 | if ( ! empty( $result->error ) ) { |
| 282 | /* translators: error message */ |
| 283 | $order->update_status( 'failed', sprintf( __( 'Unable to capture charge! %s', 'woocommerce-gateway-stripe' ), $result->error->message ) ); |
| 284 | } else { |
| 285 | $is_stripe_captured = true; |
| 286 | } |
| 287 | } elseif ( true === $result->captured ) { |
| 288 | $is_stripe_captured = true; |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | if ( $is_stripe_captured ) { |
| 293 | /* translators: transaction id */ |
| 294 | $order->add_order_note( sprintf( __( 'Stripe charge complete (Charge ID: %s)', 'woocommerce-gateway-stripe' ), $result->id ) ); |
| 295 | $order->update_meta_data( '_stripe_charge_captured', 'yes' ); |
| 296 | |
| 297 | // Store other data such as fees |
| 298 | $order->set_transaction_id( $result->id ); |
| 299 | |
| 300 | if ( is_callable( [ $order, 'save' ] ) ) { |
| 301 | $order->save(); |
| 302 | } |
| 303 | |
| 304 | $this->update_fees( $order, $result->balance_transaction->id ); |
| 305 | } |
| 306 | |
| 307 | // This hook fires when admin manually changes order status to processing or completed. |
| 308 | do_action( 'woocommerce_stripe_process_manual_capture', $order, $result ); |
| 309 | return $result; |
| 310 | } |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * Cancel pre-auth on refund/cancellation. |
| 316 | * |
| 317 | * @since 3.1.0 |
| 318 | * @version 4.2.2 |
| 319 | * @param int $order_id |
| 320 | */ |
| 321 | public function cancel_payment( $order_id ) { |
| 322 | $order = wc_get_order( $order_id ); |
| 323 | |
| 324 | if ( 'stripe' === $order->get_payment_method() ) { |
| 325 | $captured = $order->get_meta( '_stripe_charge_captured', true ); |
| 326 | if ( 'no' === $captured ) { |
| 327 | $this->process_refund( $order_id ); |
| 328 | } |
| 329 | |
| 330 | // This hook fires when admin manually changes order status to cancel. |
| 331 | do_action( 'woocommerce_stripe_process_manual_cancel', $order ); |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Filter. Adds additional meta data to Tracks events. |
| 337 | * Note that this filter is only called if WC_Site_Tracking::is_tracking_enabled. |
| 338 | * |
| 339 | * @since 4.5.1 |
| 340 | * @param array Properties to be appended to. |
| 341 | * @param string Event name, e.g. orders_edit_status_change. |
| 342 | */ |
| 343 | public function woocommerce_tracks_event_properties( $properties, $prefixed_event_name ) { |
| 344 | // Not the desired event? Bail. |
| 345 | if ( 'wcadmin_orders_edit_status_change' != $prefixed_event_name ) { |
| 346 | return $properties; |
| 347 | } |
| 348 | |
| 349 | // Properties not an array? Bail. |
| 350 | if ( ! is_array( $properties ) ) { |
| 351 | return $properties; |
| 352 | } |
| 353 | |
| 354 | // No payment_method in properties? Bail. |
| 355 | if ( ! array_key_exists( 'payment_method', $properties ) ) { |
| 356 | return $properties; |
| 357 | } |
| 358 | |
| 359 | // Not stripe? Bail. |
| 360 | if ( 'stripe' != $properties['payment_method'] ) { |
| 361 | return $properties; |
| 362 | } |
| 363 | |
| 364 | // Due diligence done. Collect the metadata. |
| 365 | $is_live = true; |
| 366 | $stripe_settings = get_option( 'woocommerce_stripe_settings', [] ); |
| 367 | if ( array_key_exists( 'testmode', $stripe_settings ) ) { |
| 368 | $is_live = 'no' === $stripe_settings['testmode']; |
| 369 | } |
| 370 | |
| 371 | $properties['admin_email'] = get_option( 'admin_email' ); |
| 372 | $properties['is_live'] = $is_live; |
| 373 | $properties['woocommerce_gateway_stripe_version'] = WC_STRIPE_VERSION; |
| 374 | $properties['woocommerce_default_country'] = get_option( 'woocommerce_default_country' ); |
| 375 | |
| 376 | return $properties; |
| 377 | } |
| 378 | } |
| 379 | |
| 380 | new WC_Stripe_Order_Handler(); |