diff --git a/modules/ppcp-api-client/src/Endpoint/class-paymentsendpoint.php b/modules/ppcp-api-client/src/Endpoint/class-paymentsendpoint.php index 6e3ec6b6d..dc162dead 100644 --- a/modules/ppcp-api-client/src/Endpoint/class-paymentsendpoint.php +++ b/modules/ppcp-api-client/src/Endpoint/class-paymentsendpoint.php @@ -138,6 +138,7 @@ class PaymentsEndpoint { * * @return Authorization * @throws RuntimeException If the request fails. + * @throws PayPalApiException If the request fails. */ public function capture( string $authorization_id ): Authorization { $bearer = $this->bearer->bearer(); @@ -155,39 +156,18 @@ class PaymentsEndpoint { $json = json_decode( $response['body'] ); if ( is_wp_error( $response ) ) { - $error = new RuntimeException( - __( 'Could not capture authorized payment.', 'woocommerce-paypal-payments' ) - ); - $this->logger->log( - 'warning', - $error->getMessage(), - array( - 'args' => $args, - 'response' => $response, - ) - ); - throw $error; + throw new RuntimeException( 'Could not capture authorized payment.' ); } $status_code = (int) wp_remote_retrieve_response_code( $response ); if ( 201 !== $status_code ) { - $error = new PayPalApiException( + throw new PayPalApiException( $json, $status_code ); - $this->logger->log( - 'warning', - $error->getMessage(), - array( - 'args' => $args, - 'response' => $response, - ) - ); - throw $error; } - $authorization = $this->authorizations_factory->from_paypal_response( $json ); - return $authorization; + return $this->authorizations_factory->from_paypal_response( $json ); } /** @@ -195,10 +175,11 @@ class PaymentsEndpoint { * * @param Refund $refund The refund to be processed. * - * @return bool + * @return void * @throws RuntimeException If the request fails. + * @throws PayPalApiException If the request fails. */ - public function refund( Refund $refund ) : bool { + public function refund( Refund $refund ) : void { $bearer = $this->bearer->bearer(); $url = trailingslashit( $this->host ) . 'v2/payments/captures/' . $refund->for_capture()->id() . '/refund'; $args = array( @@ -215,37 +196,50 @@ class PaymentsEndpoint { $json = json_decode( $response['body'] ); if ( is_wp_error( $response ) ) { - $error = new RuntimeException( - __( 'Could not refund payment.', 'woocommerce-paypal-payments' ) - ); - $this->logger->log( - 'warning', - $error->getMessage(), - array( - 'args' => $args, - 'response' => $response, - ) - ); - throw $error; + throw new RuntimeException( 'Could not refund payment.' ); } $status_code = (int) wp_remote_retrieve_response_code( $response ); if ( 201 !== $status_code ) { - $error = new PayPalApiException( + throw new PayPalApiException( $json, $status_code ); - $this->logger->log( - 'warning', - $error->getMessage(), - array( - 'args' => $args, - 'response' => $response, - ) - ); - throw $error; + } + } + + /** + * Voids a transaction. + * + * @param Authorization $authorization The PayPal payment authorization to void. + * + * @return void + * @throws RuntimeException If the request fails. + * @throws PayPalApiException If the request fails. + */ + public function void( Authorization $authorization ) : void { + $bearer = $this->bearer->bearer(); + $url = trailingslashit( $this->host ) . 'v2/payments/authorizations/' . $authorization->id() . '/void'; + $args = array( + 'method' => 'POST', + 'headers' => array( + 'Authorization' => 'Bearer ' . $bearer->token(), + 'Content-Type' => 'application/json', + 'Prefer' => 'return=representation', + ), + ); + + $response = $this->request( $url, $args ); + + if ( is_wp_error( $response ) ) { + throw new RuntimeException( 'Could not void transaction.' ); } - return true; + $status_code = (int) wp_remote_retrieve_response_code( $response ); + // Currently it can return body with 200 status, despite the docs saying that it should be 204 No content. + // We don't care much about body, so just checking that it was successful. + if ( $status_code < 200 || $status_code > 299 ) { + throw new PayPalApiException( null, $status_code ); + } } } diff --git a/modules/ppcp-api-client/src/Exception/class-paypalapiexception.php b/modules/ppcp-api-client/src/Exception/class-paypalapiexception.php index d5da7357e..6f227d001 100644 --- a/modules/ppcp-api-client/src/Exception/class-paypalapiexception.php +++ b/modules/ppcp-api-client/src/Exception/class-paypalapiexception.php @@ -39,9 +39,13 @@ class PayPalApiException extends RuntimeException { $response = new \stdClass(); } if ( ! isset( $response->message ) ) { - $response->message = __( - 'Unknown error while connecting to PayPal.', - 'woocommerce-paypal-payments' + $response->message = sprintf( + /* translators: %1$d - HTTP status code number (404, 500, ...) */ + __( + 'Unknown error while connecting to PayPal. Status code: %1$d.', + 'woocommerce-paypal-payments' + ), + $this->status_code ); } if ( ! isset( $response->name ) ) { diff --git a/modules/ppcp-button/src/Assets/class-smartbutton.php b/modules/ppcp-button/src/Assets/class-smartbutton.php index ec3f6df47..6df9ba1fe 100644 --- a/modules/ppcp-button/src/Assets/class-smartbutton.php +++ b/modules/ppcp-button/src/Assets/class-smartbutton.php @@ -608,7 +608,10 @@ class SmartButton implements SmartButtonInterface { */ private function get_3ds_contingency(): string { if ( $this->settings->has( '3d_secure_contingency' ) ) { - return $this->settings->get( '3d_secure_contingency' ); + $value = $this->settings->get( '3d_secure_contingency' ); + if ( $value ) { + return $value; + } } return 'SCA_WHEN_REQUIRED'; diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index a59b651d1..c40aba867 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -219,19 +219,22 @@ return array( 'wcgateway.processor.refunds' => static function ( $container ): RefundProcessor { $order_endpoint = $container->get( 'api.endpoint.order' ); $payments_endpoint = $container->get( 'api.endpoint.payments' ); - return new RefundProcessor( $order_endpoint, $payments_endpoint ); + $logger = $container->get( 'woocommerce.logger.woocommerce' ); + return new RefundProcessor( $order_endpoint, $payments_endpoint, $logger ); }, 'wcgateway.processor.authorized-payments' => static function ( $container ): AuthorizedPaymentsProcessor { $order_endpoint = $container->get( 'api.endpoint.order' ); $payments_endpoint = $container->get( 'api.endpoint.payments' ); - return new AuthorizedPaymentsProcessor( $order_endpoint, $payments_endpoint ); + $logger = $container->get( 'woocommerce.logger.woocommerce' ); + return new AuthorizedPaymentsProcessor( $order_endpoint, $payments_endpoint, $logger ); }, 'wcgateway.admin.render-authorize-action' => static function ( $container ): RenderAuthorizeAction { return new RenderAuthorizeAction(); }, 'wcgateway.admin.order-payment-status' => static function ( $container ): PaymentStatusOrderDetail { - return new PaymentStatusOrderDetail(); + $column = $container->get( 'wcgateway.admin.orders-payment-status-column' ); + return new PaymentStatusOrderDetail( $column ); }, 'wcgateway.admin.orders-payment-status-column' => static function ( $container ): OrderTablePaymentStatusColumn { $settings = $container->get( 'wcgateway.settings' ); diff --git a/modules/ppcp-wc-gateway/src/Admin/class-ordertablepaymentstatuscolumn.php b/modules/ppcp-wc-gateway/src/Admin/class-ordertablepaymentstatuscolumn.php index 8bce07661..392edc458 100644 --- a/modules/ppcp-wc-gateway/src/Admin/class-ordertablepaymentstatuscolumn.php +++ b/modules/ppcp-wc-gateway/src/Admin/class-ordertablepaymentstatuscolumn.php @@ -81,7 +81,7 @@ class OrderTablePaymentStatusColumn { $wc_order = wc_get_order( $wc_order_id ); - if ( ! is_a( $wc_order, \WC_Order::class ) || ! $this->render_for_order( $wc_order ) ) { + if ( ! is_a( $wc_order, \WC_Order::class ) || ! $this->should_render_for_order( $wc_order ) ) { return; } @@ -100,8 +100,14 @@ class OrderTablePaymentStatusColumn { * * @return bool */ - private function render_for_order( \WC_Order $order ): bool { - return ! empty( $order->get_meta( PayPalGateway::CAPTURED_META_KEY ) ); + public function should_render_for_order( \WC_Order $order ): bool { + $intent = $order->get_meta( PayPalGateway::INTENT_META_KEY ); + $captured = $order->get_meta( PayPalGateway::CAPTURED_META_KEY ); + $status = $order->get_status(); + $not_allowed_statuses = array( 'refunded' ); + return ! empty( $intent ) && strtoupper( self::INTENT ) === strtoupper( $intent ) && + ! empty( $captured ) && + ! in_array( $status, $not_allowed_statuses, true ); } /** @@ -111,7 +117,7 @@ class OrderTablePaymentStatusColumn { * * @return bool */ - private function is_captured( \WC_Order $wc_order ): bool { + public function is_captured( \WC_Order $wc_order ): bool { $captured = $wc_order->get_meta( PayPalGateway::CAPTURED_META_KEY ); return wc_string_to_bool( $captured ); } diff --git a/modules/ppcp-wc-gateway/src/Admin/class-paymentstatusorderdetail.php b/modules/ppcp-wc-gateway/src/Admin/class-paymentstatusorderdetail.php index 619f53eff..fceb8f523 100644 --- a/modules/ppcp-wc-gateway/src/Admin/class-paymentstatusorderdetail.php +++ b/modules/ppcp-wc-gateway/src/Admin/class-paymentstatusorderdetail.php @@ -16,6 +16,22 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; */ class PaymentStatusOrderDetail { + /** + * The capture info column. + * + * @var OrderTablePaymentStatusColumn + */ + private $column; + + /** + * PaymentStatusOrderDetail constructor. + * + * @param OrderTablePaymentStatusColumn $column The capture info column. + */ + public function __construct( OrderTablePaymentStatusColumn $column ) { + $this->column = $column; + } + /** * Renders the not captured information. * @@ -23,14 +39,8 @@ class PaymentStatusOrderDetail { */ public function render( int $wc_order_id ) { $wc_order = new \WC_Order( $wc_order_id ); - $intent = $wc_order->get_meta( PayPalGateway::INTENT_META_KEY ); - $captured = $wc_order->get_meta( PayPalGateway::CAPTURED_META_KEY ); - if ( strcasecmp( $intent, 'AUTHORIZE' ) !== 0 ) { - return; - } - - if ( ! empty( $captured ) && wc_string_to_bool( $captured ) ) { + if ( ! $this->column->should_render_for_order( $wc_order ) || $this->column->is_captured( $wc_order ) ) { return; } diff --git a/modules/ppcp-wc-gateway/src/Gateway/class-paypalgateway.php b/modules/ppcp-wc-gateway/src/Gateway/class-paypalgateway.php index fa960c09f..620e0a0a1 100644 --- a/modules/ppcp-wc-gateway/src/Gateway/class-paypalgateway.php +++ b/modules/ppcp-wc-gateway/src/Gateway/class-paypalgateway.php @@ -257,10 +257,10 @@ class PayPalGateway extends \WC_Payment_Gateway { * @return bool */ public function capture_authorized_payment( \WC_Order $wc_order ): bool { - $is_processed = $this->authorized_payments->process( $wc_order ); - $this->render_authorization_message_for_status( $this->authorized_payments->last_status() ); + $result_status = $this->authorized_payments->process( $wc_order ); + $this->render_authorization_message_for_status( $result_status ); - if ( $is_processed ) { + if ( AuthorizedPaymentsProcessor::SUCCESSFUL === $result_status ) { $wc_order->add_order_note( __( 'Payment successfully captured.', 'woocommerce-paypal-payments' ) ); @@ -270,7 +270,7 @@ class PayPalGateway extends \WC_Payment_Gateway { return true; } - if ( $this->authorized_payments->last_status() === AuthorizedPaymentsProcessor::ALREADY_CAPTURED ) { + if ( AuthorizedPaymentsProcessor::ALREADY_CAPTURED === $result_status ) { if ( $wc_order->get_status() === 'on-hold' ) { $wc_order->add_order_note( __( 'Payment successfully captured.', 'woocommerce-paypal-payments' ) @@ -293,10 +293,11 @@ class PayPalGateway extends \WC_Payment_Gateway { private function render_authorization_message_for_status( string $status ) { $message_mapping = array( - AuthorizedPaymentsProcessor::SUCCESSFUL => AuthorizeOrderActionNotice::SUCCESS, - AuthorizedPaymentsProcessor::ALREADY_CAPTURED => AuthorizeOrderActionNotice::ALREADY_CAPTURED, - AuthorizedPaymentsProcessor::INACCESSIBLE => AuthorizeOrderActionNotice::NO_INFO, - AuthorizedPaymentsProcessor::NOT_FOUND => AuthorizeOrderActionNotice::NOT_FOUND, + AuthorizedPaymentsProcessor::SUCCESSFUL => AuthorizeOrderActionNotice::SUCCESS, + AuthorizedPaymentsProcessor::ALREADY_CAPTURED => AuthorizeOrderActionNotice::ALREADY_CAPTURED, + AuthorizedPaymentsProcessor::INACCESSIBLE => AuthorizeOrderActionNotice::NO_INFO, + AuthorizedPaymentsProcessor::NOT_FOUND => AuthorizeOrderActionNotice::NOT_FOUND, + AuthorizedPaymentsProcessor::BAD_AUTHORIZATION => AuthorizeOrderActionNotice::BAD_AUTHORIZATION, ); $display_message = ( isset( $message_mapping[ $status ] ) ) ? $message_mapping[ $status ] diff --git a/modules/ppcp-wc-gateway/src/Notice/class-authorizeorderactionnotice.php b/modules/ppcp-wc-gateway/src/Notice/class-authorizeorderactionnotice.php index 663273217..114257a80 100644 --- a/modules/ppcp-wc-gateway/src/Notice/class-authorizeorderactionnotice.php +++ b/modules/ppcp-wc-gateway/src/Notice/class-authorizeorderactionnotice.php @@ -18,11 +18,12 @@ class AuthorizeOrderActionNotice { const QUERY_PARAM = 'ppcp-authorized-message'; - const NO_INFO = 81; - const ALREADY_CAPTURED = 82; - const FAILED = 83; - const SUCCESS = 84; - const NOT_FOUND = 85; + const NO_INFO = 81; + const ALREADY_CAPTURED = 82; + const FAILED = 83; + const SUCCESS = 84; + const NOT_FOUND = 85; + const BAD_AUTHORIZATION = 86; /** * Returns the current message if there is one. @@ -45,35 +46,42 @@ class AuthorizeOrderActionNotice { * @return array */ private function current_message(): array { - $messages[ self::NO_INFO ] = array( + $messages[ self::NO_INFO ] = array( 'message' => __( 'Could not retrieve information. Try again later.', 'woocommerce-paypal-payments' ), 'type' => 'error', ); - $messages[ self::ALREADY_CAPTURED ] = array( + $messages[ self::ALREADY_CAPTURED ] = array( 'message' => __( 'Payment already captured.', 'woocommerce-paypal-payments' ), 'type' => 'error', ); - $messages[ self::FAILED ] = array( + $messages[ self::FAILED ] = array( 'message' => __( - 'Failed to capture. Try again later.', + 'Failed to capture. Try again later or checks the logs.', 'woocommerce-paypal-payments' ), 'type' => 'error', ); - $messages[ self::NOT_FOUND ] = array( + $messages[ self::BAD_AUTHORIZATION ] = array( + 'message' => __( + 'Cannot capture, no valid payment authorization.', + 'woocommerce-paypal-payments' + ), + 'type' => 'error', + ); + $messages[ self::NOT_FOUND ] = array( 'message' => __( 'Could not find payment to process.', 'woocommerce-paypal-payments' ), 'type' => 'error', ); - $messages[ self::SUCCESS ] = array( + $messages[ self::SUCCESS ] = array( 'message' => __( 'Payment successfully captured.', 'woocommerce-paypal-payments' diff --git a/modules/ppcp-wc-gateway/src/Processor/class-authorizedpaymentsprocessor.php b/modules/ppcp-wc-gateway/src/Processor/class-authorizedpaymentsprocessor.php index 4b21e7198..153e8ebbc 100644 --- a/modules/ppcp-wc-gateway/src/Processor/class-authorizedpaymentsprocessor.php +++ b/modules/ppcp-wc-gateway/src/Processor/class-authorizedpaymentsprocessor.php @@ -10,6 +10,7 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\WcGateway\Processor; use Exception; +use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; @@ -22,11 +23,12 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; */ class AuthorizedPaymentsProcessor { - const SUCCESSFUL = 'SUCCESSFUL'; - const ALREADY_CAPTURED = 'ALREADY_CAPTURED'; - const FAILED = 'FAILED'; - const INACCESSIBLE = 'INACCESSIBLE'; - const NOT_FOUND = 'NOT_FOUND'; + const SUCCESSFUL = 'SUCCESSFUL'; + const ALREADY_CAPTURED = 'ALREADY_CAPTURED'; + const FAILED = 'FAILED'; + const INACCESSIBLE = 'INACCESSIBLE'; + const NOT_FOUND = 'NOT_FOUND'; + const BAD_AUTHORIZATION = 'BAD_AUTHORIZATION'; /** * The Order endpoint. @@ -43,25 +45,28 @@ class AuthorizedPaymentsProcessor { private $payments_endpoint; /** - * The last status. + * The logger. * - * @var string + * @var LoggerInterface */ - private $last_status = ''; + private $logger; /** * AuthorizedPaymentsProcessor constructor. * * @param OrderEndpoint $order_endpoint The Order endpoint. * @param PaymentsEndpoint $payments_endpoint The Payments endpoint. + * @param LoggerInterface $logger The logger. */ public function __construct( OrderEndpoint $order_endpoint, - PaymentsEndpoint $payments_endpoint + PaymentsEndpoint $payments_endpoint, + LoggerInterface $logger ) { $this->order_endpoint = $order_endpoint; $this->payments_endpoint = $payments_endpoint; + $this->logger = $logger; } /** @@ -69,46 +74,36 @@ class AuthorizedPaymentsProcessor { * * @param \WC_Order $wc_order The WooCommerce order. * - * @return bool + * @return string One of the AuthorizedPaymentsProcessor status constants. */ - public function process( \WC_Order $wc_order ): bool { + public function process( \WC_Order $wc_order ): string { try { $order = $this->paypal_order_from_wc_order( $wc_order ); } catch ( Exception $exception ) { if ( $exception->getCode() === 404 ) { - $this->last_status = self::NOT_FOUND; - return false; + return self::NOT_FOUND; } - $this->last_status = self::INACCESSIBLE; - return false; + return self::INACCESSIBLE; } $authorizations = $this->all_authorizations( $order ); - if ( ! $this->are_authorzations_to_capture( ...$authorizations ) ) { - $this->last_status = self::ALREADY_CAPTURED; - return false; + if ( ! $this->authorizations_to_capture( ...$authorizations ) ) { + if ( $this->captured_authorizations( ...$authorizations ) ) { + return self::ALREADY_CAPTURED; + } + + return self::BAD_AUTHORIZATION; } try { $this->capture_authorizations( ...$authorizations ); } catch ( Exception $exception ) { - $this->last_status = self::FAILED; - return false; + $this->logger->error( 'Failed to capture authorization: ' . $exception->getMessage() ); + return self::FAILED; } - $this->last_status = self::SUCCESSFUL; - return true; - } - - /** - * Returns the last status. - * - * @return string - */ - public function last_status(): string { - - return $this->last_status; + return self::SUCCESSFUL; } /** @@ -141,17 +136,6 @@ class AuthorizedPaymentsProcessor { return $authorizations; } - /** - * Whether Authorizations need to be captured. - * - * @param Authorization ...$authorizations All Authorizations. - * - * @return bool - */ - private function are_authorzations_to_capture( Authorization ...$authorizations ): bool { - return (bool) count( $this->authorizations_to_capture( ...$authorizations ) ); - } - /** * Captures the authorizations. * @@ -171,11 +155,38 @@ class AuthorizedPaymentsProcessor { * @return Authorization[] */ private function authorizations_to_capture( Authorization ...$authorizations ): array { + return $this->filter_authorizations( + $authorizations, + array( AuthorizationStatus::CREATED, AuthorizationStatus::PENDING ) + ); + } + + /** + * The authorizations which were captured. + * + * @param Authorization ...$authorizations All Authorizations. + * @return Authorization[] + */ + private function captured_authorizations( Authorization ...$authorizations ): array { + return $this->filter_authorizations( + $authorizations, + array( AuthorizationStatus::CAPTURED ) + ); + } + + /** + * The authorizations which need to be filtered. + * + * @param Authorization[] $authorizations All Authorizations. + * @param string[] $statuses Allowed statuses, the constants from AuthorizationStatus. + * @return Authorization[] + */ + private function filter_authorizations( array $authorizations, array $statuses ): array { return array_filter( $authorizations, - static function ( Authorization $authorization ): bool { - return $authorization->status()->is( AuthorizationStatus::CREATED ) - || $authorization->status()->is( AuthorizationStatus::PENDING ); + static function ( Authorization $authorization ) use ( $statuses ): bool { + $status = $authorization->status(); + return in_array( $status->name(), $statuses, true ); } ); } diff --git a/modules/ppcp-wc-gateway/src/Processor/class-orderprocessor.php b/modules/ppcp-wc-gateway/src/Processor/class-orderprocessor.php index 6f908c01c..022604f7e 100644 --- a/modules/ppcp-wc-gateway/src/Processor/class-orderprocessor.php +++ b/modules/ppcp-wc-gateway/src/Processor/class-orderprocessor.php @@ -188,7 +188,7 @@ class OrderProcessor { $wc_order->payment_complete(); } - if ( $this->capture_authorized_downloads( $order ) && $this->authorized_payments_processor->process( $wc_order ) ) { + if ( $this->capture_authorized_downloads( $order ) && AuthorizedPaymentsProcessor::SUCCESSFUL === $this->authorized_payments_processor->process( $wc_order ) ) { $wc_order->add_order_note( __( 'Payment successfully captured.', 'woocommerce-paypal-payments' ) ); diff --git a/modules/ppcp-wc-gateway/src/Processor/class-refundprocessor.php b/modules/ppcp-wc-gateway/src/Processor/class-refundprocessor.php index 0ace800d0..c5ca98057 100644 --- a/modules/ppcp-wc-gateway/src/Processor/class-refundprocessor.php +++ b/modules/ppcp-wc-gateway/src/Processor/class-refundprocessor.php @@ -9,10 +9,15 @@ declare( strict_types=1 ); namespace WooCommerce\PayPalCommerce\WcGateway\Processor; +use Exception; +use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\Amount; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; +use WooCommerce\PayPalCommerce\ApiClient\Entity\AuthorizationStatus; use WooCommerce\PayPalCommerce\ApiClient\Entity\Money; +use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments; use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; @@ -22,6 +27,10 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; */ class RefundProcessor { + private const REFUND_MODE_REFUND = 'refund'; + private const REFUND_MODE_VOID = 'void'; + private const REFUND_MODE_UNKNOWN = 'unknown'; + /** * The order endpoint. * @@ -36,16 +45,25 @@ class RefundProcessor { */ private $payments_endpoint; + /** + * The logger. + * + * @var LoggerInterface + */ + private $logger; + /** * RefundProcessor constructor. * * @param OrderEndpoint $order_endpoint The order endpoint. * @param PaymentsEndpoint $payments_endpoint The payments endpoint. + * @param LoggerInterface $logger The logger. */ - public function __construct( OrderEndpoint $order_endpoint, PaymentsEndpoint $payments_endpoint ) { + public function __construct( OrderEndpoint $order_endpoint, PaymentsEndpoint $payments_endpoint, LoggerInterface $logger ) { $this->order_endpoint = $order_endpoint; $this->payments_endpoint = $payments_endpoint; + $this->logger = $logger; } /** @@ -56,44 +74,115 @@ class RefundProcessor { * @param string $reason The reason for the refund. * * @return bool + * + * @phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing */ public function process( \WC_Order $wc_order, float $amount = null, string $reason = '' ) : bool { - $order_id = $wc_order->get_meta( PayPalGateway::ORDER_ID_META_KEY ); - if ( ! $order_id ) { - return false; - } try { - $order = $this->order_endpoint->order( $order_id ); - if ( ! $order ) { - return false; + $order_id = $wc_order->get_meta( PayPalGateway::ORDER_ID_META_KEY ); + if ( ! $order_id ) { + throw new RuntimeException( 'PayPal order ID not found in meta.' ); } + $order = $this->order_endpoint->order( $order_id ); + $purchase_units = $order->purchase_units(); if ( ! $purchase_units ) { - return false; + throw new RuntimeException( 'No purchase units.' ); } $payments = $purchase_units[0]->payments(); if ( ! $payments ) { - return false; - } - $captures = $payments->captures(); - if ( ! $captures ) { - return false; + throw new RuntimeException( 'No payments.' ); } - $capture = $captures[0]; - $refund = new Refund( - $capture, - $capture->invoice_id(), - $reason, - new Amount( - new Money( $amount, $wc_order->get_currency() ) + $this->logger->debug( + sprintf( + 'Trying to refund/void order %1$s, payments: %2$s.', + $order->id(), + wp_json_encode( $payments->to_array() ) ) ); - return $this->payments_endpoint->refund( $refund ); - } catch ( RuntimeException $error ) { + + $mode = $this->determine_refund_mode( $payments ); + + switch ( $mode ) { + case self::REFUND_MODE_REFUND: + $captures = $payments->captures(); + if ( ! $captures ) { + throw new RuntimeException( 'No capture.' ); + } + + $capture = $captures[0]; + $refund = new Refund( + $capture, + $capture->invoice_id(), + $reason, + new Amount( + new Money( $amount, $wc_order->get_currency() ) + ) + ); + $this->payments_endpoint->refund( $refund ); + break; + case self::REFUND_MODE_VOID: + $voidable_authorizations = array_filter( + $payments->authorizations(), + array( $this, 'is_voidable_authorization' ) + ); + if ( ! $voidable_authorizations ) { + throw new RuntimeException( 'No voidable authorizations.' ); + } + + foreach ( $voidable_authorizations as $authorization ) { + $this->payments_endpoint->void( $authorization ); + } + + $wc_order->set_status( 'refunded' ); + $wc_order->save(); + + break; + default: + throw new RuntimeException( 'Nothing to refund/void.' ); + } + + return true; + } catch ( Exception $error ) { + $this->logger->error( 'Refund failed: ' . $error->getMessage() ); return false; } } + + /** + * Determines the refunding mode. + * + * @param Payments $payments The order payments state. + * + * @return string One of the REFUND_MODE_ constants. + */ + private function determine_refund_mode( Payments $payments ): string { + $authorizations = $payments->authorizations(); + if ( $authorizations ) { + foreach ( $authorizations as $authorization ) { + if ( $this->is_voidable_authorization( $authorization ) ) { + return self::REFUND_MODE_VOID; + } + } + } + + if ( $payments->captures() ) { + return self::REFUND_MODE_REFUND; + } + + return self::REFUND_MODE_UNKNOWN; + } + + /** + * Checks whether the authorization can be voided. + * + * @param Authorization $authorization The authorization to check. + * @return bool + */ + private function is_voidable_authorization( Authorization $authorization ): bool { + return $authorization->status()->is( AuthorizationStatus::CREATED ); + } } diff --git a/tests/PHPUnit/ApiClient/Endpoint/PaymentsEndpointTest.php b/tests/PHPUnit/ApiClient/Endpoint/PaymentsEndpointTest.php index 87b2ca9e9..65112a61d 100644 --- a/tests/PHPUnit/ApiClient/Endpoint/PaymentsEndpointTest.php +++ b/tests/PHPUnit/ApiClient/Endpoint/PaymentsEndpointTest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\ApiClient\Endpoint; +use Psr\Log\NullLogger; use Requests_Utility_CaseInsensitiveDictionary; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; @@ -235,10 +236,6 @@ class PaymentsEndpointTest extends TestCase $authorizationFactory = Mockery::mock(AuthorizationFactory::class); - $logger = Mockery::mock(LoggerInterface::class); - $logger->expects('log'); - $logger->expects('debug'); - $headers = Mockery::mock(Requests_Utility_CaseInsensitiveDictionary::class); $headers->shouldReceive('getAll'); $rawResponse = [ @@ -250,7 +247,7 @@ class PaymentsEndpointTest extends TestCase $host, $bearer, $authorizationFactory, - $logger + new NullLogger() ); expect('wp_remote_get')->andReturn($rawResponse); @@ -281,15 +278,11 @@ class PaymentsEndpointTest extends TestCase 'headers' => $headers, ]; - $logger = Mockery::mock(LoggerInterface::class); - $logger->expects('log'); - $logger->expects('debug'); - $testee = new PaymentsEndpoint( $host, $bearer, $authorizationFactory, - $logger + new NullLogger() ); expect('wp_remote_get')->andReturn($rawResponse); diff --git a/tests/PHPUnit/WcGateway/Gateway/WcGatewayTest.php b/tests/PHPUnit/WcGateway/Gateway/WcGatewayTest.php index 65ed9776c..3070a2966 100644 --- a/tests/PHPUnit/WcGateway/Gateway/WcGatewayTest.php +++ b/tests/PHPUnit/WcGateway/Gateway/WcGatewayTest.php @@ -227,10 +227,7 @@ class WcGatewayTest extends TestCase $authorizedPaymentsProcessor ->expects('process') ->with($wcOrder) - ->andReturnTrue(); - $authorizedPaymentsProcessor - ->expects('last_status') - ->andReturn(AuthorizedPaymentsProcessor::SUCCESSFUL); + ->andReturn(AuthorizedPaymentsProcessor::SUCCESSFUL); $authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class); $authorizedOrderActionNotice ->expects('display_message') @@ -286,10 +283,7 @@ class WcGatewayTest extends TestCase $authorizedPaymentsProcessor ->expects('process') ->with($wcOrder) - ->andReturnFalse(); - $authorizedPaymentsProcessor - ->shouldReceive('last_status') - ->andReturn(AuthorizedPaymentsProcessor::ALREADY_CAPTURED); + ->andReturn(AuthorizedPaymentsProcessor::ALREADY_CAPTURED); $authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class); $authorizedOrderActionNotice ->expects('display_message') @@ -338,10 +332,7 @@ class WcGatewayTest extends TestCase $authorizedPaymentsProcessor ->expects('process') ->with($wcOrder) - ->andReturnFalse(); - $authorizedPaymentsProcessor - ->shouldReceive('last_status') - ->andReturn($lastStatus); + ->andReturn($lastStatus); $authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class); $authorizedOrderActionNotice ->expects('display_message') diff --git a/tests/PHPUnit/WcGateway/Processor/AuthorizedPaymentsProcessorTest.php b/tests/PHPUnit/WcGateway/Processor/AuthorizedPaymentsProcessorTest.php index f9583742f..2672d2f13 100644 --- a/tests/PHPUnit/WcGateway/Processor/AuthorizedPaymentsProcessorTest.php +++ b/tests/PHPUnit/WcGateway/Processor/AuthorizedPaymentsProcessorTest.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\WcGateway\Processor; +use Psr\Log\NullLogger; +use WC_Order; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; @@ -17,186 +19,145 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use Mockery; class AuthorizedPaymentsProcessorTest extends TestCase { + private $wcOrder; + private $paypalOrderId = 'abc'; - public function testDefault() { - $orderId = 'abc'; - $authorizationId = 'def'; - $authorizationStatus = Mockery::mock(AuthorizationStatus::class); - $authorizationStatus - ->shouldReceive('is') - ->with(AuthorizationStatus::CREATED) - ->andReturn(true); - $authorization = Mockery::mock(Authorization::class); - $authorization - ->shouldReceive('id') - ->andReturn($authorizationId); - $authorization - ->shouldReceive('status') - ->andReturn($authorizationStatus); - $payments = Mockery::mock(Payments::class); - $payments - ->expects('authorizations') - ->andReturn([$authorization]); - $purchaseUnit = Mockery::mock(PurchaseUnit::class); - $purchaseUnit - ->expects('payments') - ->andReturn($payments); - $order = Mockery::mock(Order::class); - $order - ->expects('purchase_units') - ->andReturn([$purchaseUnit]); - $orderEndpoint = Mockery::mock(OrderEndpoint::class); - $orderEndpoint - ->expects('order') - ->with($orderId) - ->andReturn($order); - $paymentsEndpoint = Mockery::mock(PaymentsEndpoint::class); - $paymentsEndpoint - ->expects('capture') - ->with($authorizationId) - ->andReturn($authorization); - $testee = new AuthorizedPaymentsProcessor($orderEndpoint, $paymentsEndpoint); + private $authorizationId = 'qwe'; - $wcOrder = Mockery::mock(\WC_Order::class); - $wcOrder - ->expects('get_meta') - ->with(PayPalGateway::ORDER_ID_META_KEY) - ->andReturn($orderId); - $this->assertTrue($testee->process($wcOrder)); - $this->assertEquals(AuthorizedPaymentsProcessor::SUCCESSFUL, $testee->last_status()); + private $paypalOrder; + + private $orderEndpoint; + + private $paymentsEndpoint; + + private $testee; + + public function setUp(): void { + parent::setUp(); + + $this->wcOrder = $this->createWcOrder($this->paypalOrderId); + + $this->paypalOrder = $this->createPaypalOrder([$this->createAuthorization($this->authorizationId, AuthorizationStatus::CREATED)]); + + $this->orderEndpoint = Mockery::mock(OrderEndpoint::class); + $this->orderEndpoint + ->shouldReceive('order') + ->with($this->paypalOrderId) + ->andReturnUsing(function () { + return $this->paypalOrder; + }) + ->byDefault(); + + $this->paymentsEndpoint = Mockery::mock(PaymentsEndpoint::class); + + $this->testee = new AuthorizedPaymentsProcessor($this->orderEndpoint, $this->paymentsEndpoint, new NullLogger()); + } + + public function testSuccess() { + $this->paymentsEndpoint + ->expects('capture') + ->with($this->authorizationId) + ->andReturn($this->createAuthorization($this->authorizationId, AuthorizationStatus::CAPTURED)); + + $this->assertEquals(AuthorizedPaymentsProcessor::SUCCESSFUL, $this->testee->process($this->wcOrder)); + } + + public function testCapturesAllCaptureable() { + $authorizations = [ + $this->createAuthorization('id1', AuthorizationStatus::CREATED), + $this->createAuthorization('id2', AuthorizationStatus::VOIDED), + $this->createAuthorization('id3', AuthorizationStatus::PENDING), + $this->createAuthorization('id4', AuthorizationStatus::CAPTURED), + $this->createAuthorization('id5', AuthorizationStatus::DENIED), + $this->createAuthorization('id6', AuthorizationStatus::EXPIRED), + $this->createAuthorization('id7', AuthorizationStatus::COMPLETED), + ]; + $this->paypalOrder = $this->createPaypalOrder($authorizations); + + foreach ([$authorizations[0], $authorizations[2]] as $authorization) { + $this->paymentsEndpoint + ->expects('capture') + ->with($authorization->id()) + ->andReturn($this->createAuthorization($authorization->id(), AuthorizationStatus::CAPTURED)); + } + + $this->assertEquals(AuthorizedPaymentsProcessor::SUCCESSFUL, $this->testee->process($this->wcOrder)); } public function testInaccessible() { - $orderId = 'abc'; - $orderEndpoint = Mockery::mock(OrderEndpoint::class); - $orderEndpoint + $this->orderEndpoint ->expects('order') - ->with($orderId) + ->with($this->paypalOrderId) ->andThrow(RuntimeException::class); - $paymentsEndpoint = Mockery::mock(PaymentsEndpoint::class); - $testee = new AuthorizedPaymentsProcessor($orderEndpoint, $paymentsEndpoint); - $wcOrder = Mockery::mock(\WC_Order::class); - $wcOrder - ->expects('get_meta') - ->with(PayPalGateway::ORDER_ID_META_KEY) - ->andReturn($orderId); - $this->assertFalse($testee->process($wcOrder)); - $this->assertEquals(AuthorizedPaymentsProcessor::INACCESSIBLE, $testee->last_status()); + $this->assertEquals(AuthorizedPaymentsProcessor::INACCESSIBLE, $this->testee->process($this->wcOrder)); } public function testNotFound() { - $orderId = 'abc'; - $orderEndpoint = Mockery::mock(OrderEndpoint::class); - $orderEndpoint + $this->orderEndpoint ->expects('order') - ->with($orderId) - ->andThrow(new RuntimeException("text", 404)); - $paymentsEndpoint = Mockery::mock(PaymentsEndpoint::class); - $testee = new AuthorizedPaymentsProcessor($orderEndpoint, $paymentsEndpoint); + ->with($this->paypalOrderId) + ->andThrow(new RuntimeException('text', 404)); - $wcOrder = Mockery::mock(\WC_Order::class); - $wcOrder - ->expects('get_meta') - ->with(PayPalGateway::ORDER_ID_META_KEY) - ->andReturn($orderId); - $this->assertFalse($testee->process($wcOrder)); - $this->assertEquals(AuthorizedPaymentsProcessor::NOT_FOUND, $testee->last_status()); + $this->assertEquals(AuthorizedPaymentsProcessor::NOT_FOUND, $this->testee->process($this->wcOrder)); } public function testCaptureFails() { - $orderId = 'abc'; - $authorizationId = 'def'; - $authorizationStatus = Mockery::mock(AuthorizationStatus::class); - $authorizationStatus - ->shouldReceive('is') - ->with(AuthorizationStatus::CREATED) - ->andReturn(true); - $authorization = Mockery::mock(Authorization::class); - $authorization - ->shouldReceive('id') - ->andReturn($authorizationId); - $authorization - ->shouldReceive('status') - ->andReturn($authorizationStatus); - $payments = Mockery::mock(Payments::class); - $payments - ->expects('authorizations') - ->andReturn([$authorization]); - $purchaseUnit = Mockery::mock(PurchaseUnit::class); - $purchaseUnit - ->expects('payments') - ->andReturn($payments); - $order = Mockery::mock(Order::class); - $order - ->expects('purchase_units') - ->andReturn([$purchaseUnit]); - $orderEndpoint = Mockery::mock(OrderEndpoint::class); - $orderEndpoint - ->expects('order') - ->with($orderId) - ->andReturn($order); - $paymentsEndpoint = Mockery::mock(PaymentsEndpoint::class); - $paymentsEndpoint + $this->paymentsEndpoint ->expects('capture') - ->with($authorizationId) + ->with($this->authorizationId) ->andThrow(RuntimeException::class); - $testee = new AuthorizedPaymentsProcessor($orderEndpoint, $paymentsEndpoint); - $wcOrder = Mockery::mock(\WC_Order::class); - $wcOrder - ->expects('get_meta') - ->with(PayPalGateway::ORDER_ID_META_KEY) - ->andReturn($orderId); - $this->assertFalse($testee->process($wcOrder)); - $this->assertEquals(AuthorizedPaymentsProcessor::FAILED, $testee->last_status()); + $this->assertEquals(AuthorizedPaymentsProcessor::FAILED, $this->testee->process($this->wcOrder)); } - public function testAllAreCaptured() { - $orderId = 'abc'; - $authorizationId = 'def'; - $authorizationStatus = Mockery::mock(AuthorizationStatus::class); - $authorizationStatus - ->shouldReceive('is') - ->with(AuthorizationStatus::CREATED) - ->andReturn(false); - $authorizationStatus - ->shouldReceive('is') - ->with(AuthorizationStatus::PENDING) - ->andReturn(false); - $authorization = Mockery::mock(Authorization::class); - $authorization - ->shouldReceive('id') - ->andReturn($authorizationId); - $authorization - ->shouldReceive('status') - ->andReturn($authorizationStatus); - $payments = Mockery::mock(Payments::class); - $payments - ->expects('authorizations') - ->andReturn([$authorization]); - $purchaseUnit = Mockery::mock(PurchaseUnit::class); - $purchaseUnit - ->expects('payments') - ->andReturn($payments); - $order = Mockery::mock(Order::class); - $order - ->expects('purchase_units') - ->andReturn([$purchaseUnit]); - $orderEndpoint = Mockery::mock(OrderEndpoint::class); - $orderEndpoint - ->expects('order') - ->with($orderId) - ->andReturn($order); - $paymentsEndpoint = Mockery::mock(PaymentsEndpoint::class); - $testee = new AuthorizedPaymentsProcessor($orderEndpoint, $paymentsEndpoint); + public function testAlreadyCaptured() { + $this->paypalOrder = $this->createPaypalOrder([$this->createAuthorization($this->authorizationId, AuthorizationStatus::CAPTURED)]); - $wcOrder = Mockery::mock(\WC_Order::class); - $wcOrder - ->expects('get_meta') - ->with(PayPalGateway::ORDER_ID_META_KEY) - ->andReturn($orderId); - $this->assertFalse($testee->process($wcOrder)); - $this->assertEquals(AuthorizedPaymentsProcessor::ALREADY_CAPTURED, $testee->last_status()); + $this->assertEquals(AuthorizedPaymentsProcessor::ALREADY_CAPTURED, $this->testee->process($this->wcOrder)); } -} \ No newline at end of file + + public function testBadAuthorization() { + $this->paypalOrder = $this->createPaypalOrder([$this->createAuthorization($this->authorizationId, AuthorizationStatus::DENIED)]); + + $this->assertEquals(AuthorizedPaymentsProcessor::BAD_AUTHORIZATION, $this->testee->process($this->wcOrder)); + } + + private function createWcOrder(string $paypalOrderId): WC_Order { + $wcOrder = Mockery::mock(WC_Order::class); + $wcOrder + ->shouldReceive('get_meta') + ->with(PayPalGateway::ORDER_ID_META_KEY) + ->andReturn($paypalOrderId); + return $wcOrder; + } + + private function createAuthorization(string $id, string $status): Authorization { + $authorization = Mockery::mock(Authorization::class); + $authorization + ->shouldReceive('id') + ->andReturn($id); + $authorization + ->shouldReceive('status') + ->andReturn(new AuthorizationStatus($status)); + return $authorization; + } + + private function createPaypalOrder(array $authorizations): Order { + $payments = Mockery::mock(Payments::class); + $payments + ->shouldReceive('authorizations') + ->andReturn($authorizations); + + $purchaseUnit = Mockery::mock(PurchaseUnit::class); + $purchaseUnit + ->shouldReceive('payments') + ->andReturn($payments); + + $order = Mockery::mock(Order::class); + $order + ->shouldReceive('purchase_units') + ->andReturn([$purchaseUnit]); + return $order; + } +}