Capture.php
453 lines
| 1 | <?php |
| 2 | /** |
| 3 | * WooCommerce Payment Gateway Framework |
| 4 | * |
| 5 | * This source file is subject to the GNU General Public License v3.0 |
| 6 | * that is bundled with this package in the file license.txt. |
| 7 | * It is also available through the world-wide-web at this URL: |
| 8 | * http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later |
| 9 | * If you did not receive a copy of the license and are unable to |
| 10 | * obtain it through the world-wide-web, please send an email |
| 11 | * to license@skyverge.com so we can send you a copy immediately. |
| 12 | * |
| 13 | * @since 3.0.0 |
| 14 | * @author WooCommerce / SkyVerge |
| 15 | * @copyright Copyright (c) 2013-2019, SkyVerge, Inc. |
| 16 | * @license http://www.gnu.org/licenses/gpl-3.0.html GNU General Public License v3.0 or later |
| 17 | * |
| 18 | * Modified by WooCommerce on 01 December 2021. |
| 19 | */ |
| 20 | |
| 21 | namespace WooCommerce\Square\Framework\PaymentGateway\Handlers; |
| 22 | |
| 23 | use WooCommerce\Square\Framework\PaymentGateway\Payment_Gateway; |
| 24 | use WooCommerce\Square\Framework\Plugin_Compatibility; |
| 25 | use WooCommerce\Square\Framework\Square_Helper; |
| 26 | use WooCommerce\Square\Framework\PaymentGateway\Api\Payment_Gateway_API_Response; |
| 27 | use WooCommerce\Square\Framework\Compatibility\Order_Compatibility; |
| 28 | use WooCommerce\Square\Utilities\Money_Utility; |
| 29 | |
| 30 | defined( 'ABSPATH' ) || exit; |
| 31 | |
| 32 | /** |
| 33 | * The transaction capture handler. |
| 34 | * |
| 35 | * @since 3.0.0 |
| 36 | */ |
| 37 | class Capture { |
| 38 | |
| 39 | |
| 40 | /** @var Payment_Gateway payment gateway instance */ |
| 41 | private $gateway; |
| 42 | |
| 43 | |
| 44 | /** |
| 45 | * Capture constructor. |
| 46 | * |
| 47 | * @since 3.0.0 |
| 48 | * |
| 49 | * @param Payment_Gateway $gateway payment gateway instance |
| 50 | */ |
| 51 | public function __construct( Payment_Gateway $gateway ) { |
| 52 | |
| 53 | $this->gateway = $gateway; |
| 54 | |
| 55 | // auto-capture on order status change if enabled |
| 56 | if ( $gateway->supports_capture() && $gateway->is_paid_capture_enabled() ) { |
| 57 | add_action( 'woocommerce_order_status_changed', array( $this, 'maybe_capture_paid_order' ), 10, 3 ); |
| 58 | } |
| 59 | } |
| 60 | |
| 61 | |
| 62 | /** |
| 63 | * Captures an order on status change to a "paid" status. |
| 64 | * |
| 65 | * @internal |
| 66 | * |
| 67 | * @since 3.0.0 |
| 68 | * |
| 69 | * @param int $order_id order ID |
| 70 | * @param string $old_status status being changed |
| 71 | * @param string $new_status new order status |
| 72 | */ |
| 73 | public function maybe_capture_paid_order( $order_id, $old_status, $new_status ) { |
| 74 | |
| 75 | $paid_statuses = wc_get_is_paid_statuses(); |
| 76 | |
| 77 | // bail if changing to a non-paid status or from a paid status |
| 78 | if ( ! in_array( $new_status, $paid_statuses, true ) || in_array( $old_status, $paid_statuses, true ) ) { |
| 79 | return; |
| 80 | } |
| 81 | |
| 82 | $order = wc_get_order( $order_id ); |
| 83 | |
| 84 | if ( ! $order ) { |
| 85 | return; |
| 86 | } |
| 87 | |
| 88 | $payment_method = Order_Compatibility::get_prop( $order, 'payment_method' ); |
| 89 | |
| 90 | if ( $payment_method !== $this->get_gateway()->get_id() ) { |
| 91 | return; |
| 92 | } |
| 93 | |
| 94 | $this->maybe_perform_capture( $order ); |
| 95 | } |
| 96 | |
| 97 | |
| 98 | /** |
| 99 | * Perform a capture on an order if it can be captured. |
| 100 | * |
| 101 | * This acts as a wrapper for when the process should just bail without logging any errors or order notes, like when |
| 102 | * performing capture via bulk action. |
| 103 | * |
| 104 | * @since 3.0.0 |
| 105 | * |
| 106 | * @param \WC_Order $order order object |
| 107 | * @param float|null $amount amount to capture |
| 108 | * @return bool |
| 109 | */ |
| 110 | public function maybe_perform_capture( \WC_Order $order, $amount = null ) { |
| 111 | |
| 112 | // don't log any errors for for orders that can't be captured |
| 113 | if ( ! $this->order_can_be_captured( $order ) ) { |
| 114 | return false; |
| 115 | } |
| 116 | |
| 117 | $result = $this->perform_capture( $order, $amount ); |
| 118 | |
| 119 | return ! empty( $result['success'] ); |
| 120 | } |
| 121 | |
| 122 | |
| 123 | /** |
| 124 | * Performs a credit card capture for an order. |
| 125 | * |
| 126 | * @since 3.0.0 |
| 127 | * |
| 128 | * @param \WC_Order $order WooCommerce order object |
| 129 | * @param float|null $amount amount to capture |
| 130 | * @return array { |
| 131 | * Capture transaction results |
| 132 | * |
| 133 | * @type bool $success whether the capture was successful |
| 134 | * @type int $code result code |
| 135 | * @type string $message result message |
| 136 | * } |
| 137 | */ |
| 138 | public function perform_capture( \WC_Order $order, $amount = null ) { |
| 139 | |
| 140 | $order = $this->get_gateway()->get_order_for_capture( $order, $amount ); |
| 141 | |
| 142 | try { |
| 143 | |
| 144 | // notify if the gateway doesn't support captures when this is called directly |
| 145 | if ( ! $this->get_gateway()->supports_capture() ) { |
| 146 | |
| 147 | $message = "{$this->get_gateway()->get_method_title()} does not support payment captures"; |
| 148 | |
| 149 | Plugin_Compatibility::wc_doing_it_wrong( __METHOD__, $message, '3.0.0' ); |
| 150 | |
| 151 | throw new \Exception( $message, 500 ); |
| 152 | } |
| 153 | |
| 154 | // don't try to capture failed/cancelled/fully refunded transactions |
| 155 | if ( ! $this->is_order_ready_for_capture( $order ) ) { |
| 156 | throw new \Exception( __( 'Order cannot be captured', 'woocommerce-square' ), 400 ); |
| 157 | } |
| 158 | |
| 159 | // don't re-capture fully captured orders |
| 160 | if ( $this->has_order_authorization_expired( $order ) ) { |
| 161 | throw new \Exception( __( 'Transaction authorization has expired', 'woocommerce-square' ), 400 ); |
| 162 | } |
| 163 | |
| 164 | // don't re-capture fully captured orders |
| 165 | if ( $this->is_order_fully_captured( $order ) ) { |
| 166 | throw new \Exception( __( 'Transaction has already been fully captured', 'woocommerce-square' ), 400 ); |
| 167 | } |
| 168 | |
| 169 | // generally unavailable |
| 170 | if ( ! $this->order_can_be_captured( $order ) ) { |
| 171 | throw new \Exception( __( 'Transaction cannot be captured', 'woocommerce-square' ), 400 ); |
| 172 | } |
| 173 | |
| 174 | // Handle capture for orders where the payment was split between Square Gift and Credit cards. |
| 175 | if ( $this->get_gateway()->get_order_meta( $order, 'charge_type' ) === $this->get_gateway()::CHARGE_TYPE_PARTIAL ) { |
| 176 | $transaction_id = $this->get_gateway()->get_order_meta( $order, 'trans_id' ); |
| 177 | $gift_card_transaction_id = $this->get_gateway()->get_order_meta( $order, 'gift_card_trans_id' ); |
| 178 | $square_order_id = $this->get_gateway()->get_order_meta( $order, 'square_order_id' ); |
| 179 | |
| 180 | $response = $this->get_gateway()->get_api()->pay_order( |
| 181 | array( |
| 182 | $transaction_id, |
| 183 | $gift_card_transaction_id, |
| 184 | ), |
| 185 | $square_order_id |
| 186 | ); |
| 187 | } else { |
| 188 | // attempt the capture |
| 189 | $response = $this->get_gateway()->get_api()->capture_payment( $order ); |
| 190 | } |
| 191 | |
| 192 | $gift_card_purchase_type = \WooCommerce\Square\Handlers\Order::get_gift_card_purchase_type( $order ); |
| 193 | |
| 194 | if ( 'new' === $gift_card_purchase_type ) { |
| 195 | $this->get_gateway()->create_gift_card( $order ); |
| 196 | } elseif ( 'load' === $gift_card_purchase_type ) { |
| 197 | $gan = \WooCommerce\Square\Handlers\Order::get_gift_card_gan( $order ); |
| 198 | $this->get_gateway()->load_gift_card( $gan, $order ); |
| 199 | } |
| 200 | |
| 201 | // bail early if the capture wasn't approved |
| 202 | if ( $response->has_errors() ) { |
| 203 | |
| 204 | $this->do_capture_failed( $order, $response ); |
| 205 | |
| 206 | throw new \Exception( $response->get_status_code() . ' - ' . $response->get_status_message() ); |
| 207 | } |
| 208 | |
| 209 | $message = sprintf( |
| 210 | /* translators: Placeholders: %1$s - payment gateway title (such as Authorize.net, Braintree, etc), %2$s - transaction amount. Definitions: Capture, as in capture funds from a credit card. */ |
| 211 | esc_html__( '%1$s Capture total of %2$s Approved', 'woocommerce-square' ), |
| 212 | $this->get_gateway()->get_method_title(), |
| 213 | wc_price( $order->capture->amount, array( 'currency' => Order_Compatibility::get_prop( $order, 'currency', 'view' ) ) ) |
| 214 | ); |
| 215 | |
| 216 | // adds the transaction id (if any) to the order note |
| 217 | if ( $response->get_data() instanceof \Square\Models\PayOrderResponse && ! empty( $response->get_transaction_ids() ) ) { |
| 218 | /** @var \Square\Models\Order $order_response */ |
| 219 | $square_order = $response->get_order(); |
| 220 | $message .= ' ' . $this->get_gateway()->build_split_payment_order_note( $order, $square_order ); |
| 221 | /* translators: %s list of transaction IDs for split payments. */ |
| 222 | $message .= ' ' . sprintf( esc_html__( '(Transaction IDs %s)', 'woocommerce-square' ), implode( ', ', $response->get_transaction_ids() ) ); |
| 223 | } elseif ( $response->get_transaction_id() ) { |
| 224 | /* translators: %s transaction ID */ |
| 225 | $message .= ' ' . sprintf( esc_html__( '(Transaction ID %s)', 'woocommerce-square' ), $response->get_transaction_id() ); |
| 226 | } |
| 227 | |
| 228 | $order->add_order_note( $message ); |
| 229 | |
| 230 | // add the standard capture data to the order |
| 231 | $this->do_capture_success( $order, $response ); |
| 232 | |
| 233 | // if the original auth amount has been captured, complete payment |
| 234 | if ( $this->get_gateway()->get_order_meta( $order, 'capture_total' ) >= $order->get_total() ) { |
| 235 | |
| 236 | // prevent stock from being reduced when payment is completed as this is done when the charge was authorized |
| 237 | add_filter( 'woocommerce_payment_complete_reduce_order_stock', '__return_false', 100 ); |
| 238 | |
| 239 | // complete the order |
| 240 | $order->payment_complete(); |
| 241 | } |
| 242 | |
| 243 | return array( |
| 244 | 'success' => true, |
| 245 | 'code' => 200, |
| 246 | 'message' => $message, |
| 247 | ); |
| 248 | |
| 249 | } catch ( \Exception $exception ) { |
| 250 | |
| 251 | // add an order note if this isn't a general error |
| 252 | if ( 500 !== $exception->getCode() ) { |
| 253 | |
| 254 | $note_message = sprintf( |
| 255 | /* translators: Placeholders: %1$s - payment gateway title (such as Authorize.net, Braintree, etc), %2$s - failure message. Definitions: "capture" as in capturing funds from a credit card. */ |
| 256 | esc_html__( '%1$s Capture Failed: %2$s', 'woocommerce-square' ), |
| 257 | $this->get_gateway()->get_method_title(), |
| 258 | $exception->getMessage() |
| 259 | ); |
| 260 | |
| 261 | $order->add_order_note( $note_message ); |
| 262 | } |
| 263 | |
| 264 | return array( |
| 265 | 'success' => false, |
| 266 | 'code' => $exception->getCode(), |
| 267 | 'message' => $exception->getMessage(), |
| 268 | ); |
| 269 | } |
| 270 | } |
| 271 | |
| 272 | |
| 273 | /** |
| 274 | * Adds the standard capture data to an order. |
| 275 | * |
| 276 | * @since 3.0.0 |
| 277 | * |
| 278 | * @param \WC_Order $order the order object |
| 279 | * @param Payment_Gateway_API_Response $response transaction response |
| 280 | */ |
| 281 | public function do_capture_success( \WC_Order $order, Payment_Gateway_API_Response $response ) { |
| 282 | |
| 283 | $total_captured = (float) $this->get_gateway()->get_order_meta( $order, 'capture_total' ) + (float) $order->capture->amount; |
| 284 | |
| 285 | $this->get_gateway()->update_order_meta( $order, 'capture_total', Square_Helper::number_format( $total_captured ) ); |
| 286 | $this->get_gateway()->update_order_meta( $order, 'charge_captured', $this->get_gateway()->supports_partial_capture() && $this->get_gateway()->is_partial_capture_enabled() && $total_captured < (float) $this->get_order_capture_maximum( $order ) ? 'partial' : 'yes' ); |
| 287 | |
| 288 | // add capture transaction ID |
| 289 | if ( $response && $response->get_transaction_id() ) { |
| 290 | $this->get_gateway()->update_order_meta( $order, 'capture_trans_id', $response->get_transaction_id() ); |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | |
| 295 | /** |
| 296 | * Lets gateways handle any specific capture failure results for the order. |
| 297 | * |
| 298 | * @since 3.0.0 |
| 299 | * |
| 300 | * @param \WC_Order $order WooCommerce order object |
| 301 | * @param Payment_Gateway_API_Response $response API response object |
| 302 | */ |
| 303 | public function do_capture_failed( \WC_Order $order, Payment_Gateway_API_Response $response ) { } |
| 304 | |
| 305 | |
| 306 | /** Conditional Methods *******************************************************************************************/ |
| 307 | |
| 308 | |
| 309 | /** |
| 310 | * Determines if an order is eligible for capture. |
| 311 | * |
| 312 | * @since 3.0.0 |
| 313 | * |
| 314 | * @param \WC_Order $order order object |
| 315 | * @return bool |
| 316 | */ |
| 317 | public function order_can_be_captured( \WC_Order $order ) { |
| 318 | |
| 319 | // check whether the charge has already been captured by this gateway |
| 320 | if ( ! $this->is_order_ready_for_capture( $order ) || $this->is_order_fully_captured( $order ) ) { |
| 321 | return false; |
| 322 | } |
| 323 | |
| 324 | // if for any reason the authorization can not be captured |
| 325 | if ( 'no' === $this->get_gateway()->get_order_meta( $order, 'auth_can_be_captured' ) ) { |
| 326 | return false; |
| 327 | } |
| 328 | |
| 329 | // authorization hasn't already been captured, but has it expired? |
| 330 | return ! $this->has_order_authorization_expired( $order ); |
| 331 | } |
| 332 | |
| 333 | |
| 334 | /** |
| 335 | * Determines if an order is ready for capture. |
| 336 | * |
| 337 | * The base implementation of this method checks for a valid order status and that a transaction ID is set. |
| 338 | * |
| 339 | * @since 3.0.0 |
| 340 | * |
| 341 | * @param \WC_Order $order order object |
| 342 | * @return bool |
| 343 | */ |
| 344 | public function is_order_ready_for_capture( \WC_Order $order ) { |
| 345 | |
| 346 | return ! in_array( $order->get_status(), array( 'cancelled', 'refunded', 'failed' ), true ) && $this->get_gateway()->get_order_meta( $order, 'trans_id' ); |
| 347 | } |
| 348 | |
| 349 | |
| 350 | /** |
| 351 | * Determines if an order has been fully captured |
| 352 | * |
| 353 | * @since 3.0.0 |
| 354 | * |
| 355 | * @param \WC_Order $order |
| 356 | * @return bool |
| 357 | */ |
| 358 | public function is_order_fully_captured( \WC_Order $order ) { |
| 359 | |
| 360 | $captured = 'yes' === $this->get_gateway()->get_order_meta( $order, 'charge_captured' ); |
| 361 | |
| 362 | if ( ! $captured && $this->get_gateway()->supports_partial_capture() && $this->get_gateway()->is_partial_capture_enabled() ) { |
| 363 | $captured = (float) $this->get_gateway()->get_order_meta( $order, 'capture_total' ) >= (float) $this->get_order_capture_maximum( $order ); |
| 364 | } |
| 365 | |
| 366 | return $captured; |
| 367 | } |
| 368 | |
| 369 | |
| 370 | /** |
| 371 | * Determines if an order's authorization has expired. |
| 372 | * |
| 373 | * @since 3.0.0 |
| 374 | * |
| 375 | * @param \WC_Order $order |
| 376 | * @return bool |
| 377 | */ |
| 378 | public function has_order_authorization_expired( \WC_Order $order ) { |
| 379 | |
| 380 | $transaction_date = $this->get_gateway()->get_order_meta( Order_Compatibility::get_prop( $order, 'id' ), 'trans_date' ); |
| 381 | |
| 382 | $transaction_time = strtotime( $transaction_date ); |
| 383 | |
| 384 | return $transaction_date && floor( ( time() - $transaction_time ) / 3600 ) > $this->get_gateway()->get_authorization_time_window(); |
| 385 | } |
| 386 | |
| 387 | |
| 388 | /** |
| 389 | * Determines if an order's authorization has been captured, even partially. |
| 390 | * |
| 391 | * @since 3.0.0 |
| 392 | * |
| 393 | * @param \WC_Order $order order object |
| 394 | * @return bool |
| 395 | */ |
| 396 | public function is_order_captured( \WC_Order $order ) { |
| 397 | |
| 398 | return in_array( $this->get_gateway()->get_order_meta( $order, 'charge_captured' ), array( 'yes', 'partial' ), true ); |
| 399 | } |
| 400 | |
| 401 | |
| 402 | /** Getter Methods ************************************************************************************************/ |
| 403 | |
| 404 | |
| 405 | /** |
| 406 | * Gets the maximum amount that can be captured from an order. |
| 407 | * |
| 408 | * Gateways can override this for an value above or below the order total. |
| 409 | * For instance, some processors allow capturing an amount a certain |
| 410 | * percentage higher than the payment total. |
| 411 | * |
| 412 | * @since 3.0.0 |
| 413 | * |
| 414 | * @param \WC_Order $order WooCommerce order object |
| 415 | * @return float |
| 416 | */ |
| 417 | public function get_order_capture_maximum( \WC_Order $order ) { |
| 418 | |
| 419 | return $this->get_order_authorization_amount( $order ); |
| 420 | } |
| 421 | |
| 422 | |
| 423 | /** |
| 424 | * Gets the amount originally authorized for an order. |
| 425 | * |
| 426 | * @since 3.0.0 |
| 427 | * |
| 428 | * @param \WC_Order $order order object |
| 429 | * @return float |
| 430 | */ |
| 431 | public function get_order_authorization_amount( \WC_Order $order ) { |
| 432 | |
| 433 | // if a specific auth amount was stored, use it |
| 434 | // otherwise, use the order total |
| 435 | $amount = $this->get_gateway()->get_order_meta( $order, 'authorization_amount' ); |
| 436 | $amount = $amount ? $amount : $order->get_total(); |
| 437 | |
| 438 | return (float) $amount; |
| 439 | } |
| 440 | |
| 441 | |
| 442 | /** |
| 443 | * Gets the payment gateway instance. |
| 444 | * |
| 445 | * @since 3.0.0 |
| 446 | * |
| 447 | * @return Payment_Gateway |
| 448 | */ |
| 449 | protected function get_gateway() { |
| 450 | return $this->gateway; |
| 451 | } |
| 452 | } |
| 453 |