Merge branch 'trunk' into PCP-327-prevent-subscription-from-being-

This commit is contained in:
dinamiko 2021-10-06 12:35:08 +02:00
commit a21aa9111a
14 changed files with 412 additions and 338 deletions

View file

@ -138,6 +138,7 @@ class PaymentsEndpoint {
* *
* @return Authorization * @return Authorization
* @throws RuntimeException If the request fails. * @throws RuntimeException If the request fails.
* @throws PayPalApiException If the request fails.
*/ */
public function capture( string $authorization_id ): Authorization { public function capture( string $authorization_id ): Authorization {
$bearer = $this->bearer->bearer(); $bearer = $this->bearer->bearer();
@ -155,39 +156,18 @@ class PaymentsEndpoint {
$json = json_decode( $response['body'] ); $json = json_decode( $response['body'] );
if ( is_wp_error( $response ) ) { if ( is_wp_error( $response ) ) {
$error = new RuntimeException( throw new RuntimeException( 'Could not capture authorized payment.' );
__( 'Could not capture authorized payment.', 'woocommerce-paypal-payments' )
);
$this->logger->log(
'warning',
$error->getMessage(),
array(
'args' => $args,
'response' => $response,
)
);
throw $error;
} }
$status_code = (int) wp_remote_retrieve_response_code( $response ); $status_code = (int) wp_remote_retrieve_response_code( $response );
if ( 201 !== $status_code ) { if ( 201 !== $status_code ) {
$error = new PayPalApiException( throw new PayPalApiException(
$json, $json,
$status_code $status_code
); );
$this->logger->log(
'warning',
$error->getMessage(),
array(
'args' => $args,
'response' => $response,
)
);
throw $error;
} }
$authorization = $this->authorizations_factory->from_paypal_response( $json ); return $this->authorizations_factory->from_paypal_response( $json );
return $authorization;
} }
/** /**
@ -195,10 +175,11 @@ class PaymentsEndpoint {
* *
* @param Refund $refund The refund to be processed. * @param Refund $refund The refund to be processed.
* *
* @return bool * @return void
* @throws RuntimeException If the request fails. * @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(); $bearer = $this->bearer->bearer();
$url = trailingslashit( $this->host ) . 'v2/payments/captures/' . $refund->for_capture()->id() . '/refund'; $url = trailingslashit( $this->host ) . 'v2/payments/captures/' . $refund->for_capture()->id() . '/refund';
$args = array( $args = array(
@ -215,37 +196,50 @@ class PaymentsEndpoint {
$json = json_decode( $response['body'] ); $json = json_decode( $response['body'] );
if ( is_wp_error( $response ) ) { if ( is_wp_error( $response ) ) {
$error = new RuntimeException( throw new RuntimeException( 'Could not refund payment.' );
__( 'Could not refund payment.', 'woocommerce-paypal-payments' )
);
$this->logger->log(
'warning',
$error->getMessage(),
array(
'args' => $args,
'response' => $response,
)
);
throw $error;
} }
$status_code = (int) wp_remote_retrieve_response_code( $response ); $status_code = (int) wp_remote_retrieve_response_code( $response );
if ( 201 !== $status_code ) { if ( 201 !== $status_code ) {
$error = new PayPalApiException( throw new PayPalApiException(
$json, $json,
$status_code $status_code
); );
$this->logger->log( }
'warning', }
$error->getMessage(),
array( /**
'args' => $args, * Voids a transaction.
'response' => $response, *
) * @param Authorization $authorization The PayPal payment authorization to void.
); *
throw $error; * @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 );
}
} }
} }

View file

@ -39,9 +39,13 @@ class PayPalApiException extends RuntimeException {
$response = new \stdClass(); $response = new \stdClass();
} }
if ( ! isset( $response->message ) ) { if ( ! isset( $response->message ) ) {
$response->message = __( $response->message = sprintf(
'Unknown error while connecting to PayPal.', /* translators: %1$d - HTTP status code number (404, 500, ...) */
'woocommerce-paypal-payments' __(
'Unknown error while connecting to PayPal. Status code: %1$d.',
'woocommerce-paypal-payments'
),
$this->status_code
); );
} }
if ( ! isset( $response->name ) ) { if ( ! isset( $response->name ) ) {

View file

@ -608,7 +608,10 @@ class SmartButton implements SmartButtonInterface {
*/ */
private function get_3ds_contingency(): string { private function get_3ds_contingency(): string {
if ( $this->settings->has( '3d_secure_contingency' ) ) { 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'; return 'SCA_WHEN_REQUIRED';

View file

@ -219,19 +219,22 @@ return array(
'wcgateway.processor.refunds' => static function ( $container ): RefundProcessor { 'wcgateway.processor.refunds' => static function ( $container ): RefundProcessor {
$order_endpoint = $container->get( 'api.endpoint.order' ); $order_endpoint = $container->get( 'api.endpoint.order' );
$payments_endpoint = $container->get( 'api.endpoint.payments' ); $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 { 'wcgateway.processor.authorized-payments' => static function ( $container ): AuthorizedPaymentsProcessor {
$order_endpoint = $container->get( 'api.endpoint.order' ); $order_endpoint = $container->get( 'api.endpoint.order' );
$payments_endpoint = $container->get( 'api.endpoint.payments' ); $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 { 'wcgateway.admin.render-authorize-action' => static function ( $container ): RenderAuthorizeAction {
return new RenderAuthorizeAction(); return new RenderAuthorizeAction();
}, },
'wcgateway.admin.order-payment-status' => static function ( $container ): PaymentStatusOrderDetail { '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 { 'wcgateway.admin.orders-payment-status-column' => static function ( $container ): OrderTablePaymentStatusColumn {
$settings = $container->get( 'wcgateway.settings' ); $settings = $container->get( 'wcgateway.settings' );

View file

@ -81,7 +81,7 @@ class OrderTablePaymentStatusColumn {
$wc_order = wc_get_order( $wc_order_id ); $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; return;
} }
@ -100,8 +100,14 @@ class OrderTablePaymentStatusColumn {
* *
* @return bool * @return bool
*/ */
private function render_for_order( \WC_Order $order ): bool { public function should_render_for_order( \WC_Order $order ): bool {
return ! empty( $order->get_meta( PayPalGateway::CAPTURED_META_KEY ) ); $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 * @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 ); $captured = $wc_order->get_meta( PayPalGateway::CAPTURED_META_KEY );
return wc_string_to_bool( $captured ); return wc_string_to_bool( $captured );
} }

View file

@ -16,6 +16,22 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
*/ */
class PaymentStatusOrderDetail { 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. * Renders the not captured information.
* *
@ -23,14 +39,8 @@ class PaymentStatusOrderDetail {
*/ */
public function render( int $wc_order_id ) { public function render( int $wc_order_id ) {
$wc_order = new \WC_Order( $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 ) { if ( ! $this->column->should_render_for_order( $wc_order ) || $this->column->is_captured( $wc_order ) ) {
return;
}
if ( ! empty( $captured ) && wc_string_to_bool( $captured ) ) {
return; return;
} }

View file

@ -257,10 +257,10 @@ class PayPalGateway extends \WC_Payment_Gateway {
* @return bool * @return bool
*/ */
public function capture_authorized_payment( \WC_Order $wc_order ): bool { public function capture_authorized_payment( \WC_Order $wc_order ): bool {
$is_processed = $this->authorized_payments->process( $wc_order ); $result_status = $this->authorized_payments->process( $wc_order );
$this->render_authorization_message_for_status( $this->authorized_payments->last_status() ); $this->render_authorization_message_for_status( $result_status );
if ( $is_processed ) { if ( AuthorizedPaymentsProcessor::SUCCESSFUL === $result_status ) {
$wc_order->add_order_note( $wc_order->add_order_note(
__( 'Payment successfully captured.', 'woocommerce-paypal-payments' ) __( 'Payment successfully captured.', 'woocommerce-paypal-payments' )
); );
@ -270,7 +270,7 @@ class PayPalGateway extends \WC_Payment_Gateway {
return true; return true;
} }
if ( $this->authorized_payments->last_status() === AuthorizedPaymentsProcessor::ALREADY_CAPTURED ) { if ( AuthorizedPaymentsProcessor::ALREADY_CAPTURED === $result_status ) {
if ( $wc_order->get_status() === 'on-hold' ) { if ( $wc_order->get_status() === 'on-hold' ) {
$wc_order->add_order_note( $wc_order->add_order_note(
__( 'Payment successfully captured.', 'woocommerce-paypal-payments' ) __( '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 ) { private function render_authorization_message_for_status( string $status ) {
$message_mapping = array( $message_mapping = array(
AuthorizedPaymentsProcessor::SUCCESSFUL => AuthorizeOrderActionNotice::SUCCESS, AuthorizedPaymentsProcessor::SUCCESSFUL => AuthorizeOrderActionNotice::SUCCESS,
AuthorizedPaymentsProcessor::ALREADY_CAPTURED => AuthorizeOrderActionNotice::ALREADY_CAPTURED, AuthorizedPaymentsProcessor::ALREADY_CAPTURED => AuthorizeOrderActionNotice::ALREADY_CAPTURED,
AuthorizedPaymentsProcessor::INACCESSIBLE => AuthorizeOrderActionNotice::NO_INFO, AuthorizedPaymentsProcessor::INACCESSIBLE => AuthorizeOrderActionNotice::NO_INFO,
AuthorizedPaymentsProcessor::NOT_FOUND => AuthorizeOrderActionNotice::NOT_FOUND, AuthorizedPaymentsProcessor::NOT_FOUND => AuthorizeOrderActionNotice::NOT_FOUND,
AuthorizedPaymentsProcessor::BAD_AUTHORIZATION => AuthorizeOrderActionNotice::BAD_AUTHORIZATION,
); );
$display_message = ( isset( $message_mapping[ $status ] ) ) ? $display_message = ( isset( $message_mapping[ $status ] ) ) ?
$message_mapping[ $status ] $message_mapping[ $status ]

View file

@ -18,11 +18,12 @@ class AuthorizeOrderActionNotice {
const QUERY_PARAM = 'ppcp-authorized-message'; const QUERY_PARAM = 'ppcp-authorized-message';
const NO_INFO = 81; const NO_INFO = 81;
const ALREADY_CAPTURED = 82; const ALREADY_CAPTURED = 82;
const FAILED = 83; const FAILED = 83;
const SUCCESS = 84; const SUCCESS = 84;
const NOT_FOUND = 85; const NOT_FOUND = 85;
const BAD_AUTHORIZATION = 86;
/** /**
* Returns the current message if there is one. * Returns the current message if there is one.
@ -45,35 +46,42 @@ class AuthorizeOrderActionNotice {
* @return array * @return array
*/ */
private function current_message(): array { private function current_message(): array {
$messages[ self::NO_INFO ] = array( $messages[ self::NO_INFO ] = array(
'message' => __( 'message' => __(
'Could not retrieve information. Try again later.', 'Could not retrieve information. Try again later.',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'
), ),
'type' => 'error', 'type' => 'error',
); );
$messages[ self::ALREADY_CAPTURED ] = array( $messages[ self::ALREADY_CAPTURED ] = array(
'message' => __( 'message' => __(
'Payment already captured.', 'Payment already captured.',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'
), ),
'type' => 'error', 'type' => 'error',
); );
$messages[ self::FAILED ] = array( $messages[ self::FAILED ] = array(
'message' => __( 'message' => __(
'Failed to capture. Try again later.', 'Failed to capture. Try again later or checks the logs.',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'
), ),
'type' => 'error', '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' => __( 'message' => __(
'Could not find payment to process.', 'Could not find payment to process.',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'
), ),
'type' => 'error', 'type' => 'error',
); );
$messages[ self::SUCCESS ] = array( $messages[ self::SUCCESS ] = array(
'message' => __( 'message' => __(
'Payment successfully captured.', 'Payment successfully captured.',
'woocommerce-paypal-payments' 'woocommerce-paypal-payments'

View file

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Processor; namespace WooCommerce\PayPalCommerce\WcGateway\Processor;
use Exception; use Exception;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization;
@ -22,11 +23,12 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
*/ */
class AuthorizedPaymentsProcessor { class AuthorizedPaymentsProcessor {
const SUCCESSFUL = 'SUCCESSFUL'; const SUCCESSFUL = 'SUCCESSFUL';
const ALREADY_CAPTURED = 'ALREADY_CAPTURED'; const ALREADY_CAPTURED = 'ALREADY_CAPTURED';
const FAILED = 'FAILED'; const FAILED = 'FAILED';
const INACCESSIBLE = 'INACCESSIBLE'; const INACCESSIBLE = 'INACCESSIBLE';
const NOT_FOUND = 'NOT_FOUND'; const NOT_FOUND = 'NOT_FOUND';
const BAD_AUTHORIZATION = 'BAD_AUTHORIZATION';
/** /**
* The Order endpoint. * The Order endpoint.
@ -43,25 +45,28 @@ class AuthorizedPaymentsProcessor {
private $payments_endpoint; private $payments_endpoint;
/** /**
* The last status. * The logger.
* *
* @var string * @var LoggerInterface
*/ */
private $last_status = ''; private $logger;
/** /**
* AuthorizedPaymentsProcessor constructor. * AuthorizedPaymentsProcessor constructor.
* *
* @param OrderEndpoint $order_endpoint The Order endpoint. * @param OrderEndpoint $order_endpoint The Order endpoint.
* @param PaymentsEndpoint $payments_endpoint The Payments endpoint. * @param PaymentsEndpoint $payments_endpoint The Payments endpoint.
* @param LoggerInterface $logger The logger.
*/ */
public function __construct( public function __construct(
OrderEndpoint $order_endpoint, OrderEndpoint $order_endpoint,
PaymentsEndpoint $payments_endpoint PaymentsEndpoint $payments_endpoint,
LoggerInterface $logger
) { ) {
$this->order_endpoint = $order_endpoint; $this->order_endpoint = $order_endpoint;
$this->payments_endpoint = $payments_endpoint; $this->payments_endpoint = $payments_endpoint;
$this->logger = $logger;
} }
/** /**
@ -69,46 +74,36 @@ class AuthorizedPaymentsProcessor {
* *
* @param \WC_Order $wc_order The WooCommerce order. * @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 { try {
$order = $this->paypal_order_from_wc_order( $wc_order ); $order = $this->paypal_order_from_wc_order( $wc_order );
} catch ( Exception $exception ) { } catch ( Exception $exception ) {
if ( $exception->getCode() === 404 ) { if ( $exception->getCode() === 404 ) {
$this->last_status = self::NOT_FOUND; return self::NOT_FOUND;
return false;
} }
$this->last_status = self::INACCESSIBLE; return self::INACCESSIBLE;
return false;
} }
$authorizations = $this->all_authorizations( $order ); $authorizations = $this->all_authorizations( $order );
if ( ! $this->are_authorzations_to_capture( ...$authorizations ) ) { if ( ! $this->authorizations_to_capture( ...$authorizations ) ) {
$this->last_status = self::ALREADY_CAPTURED; if ( $this->captured_authorizations( ...$authorizations ) ) {
return false; return self::ALREADY_CAPTURED;
}
return self::BAD_AUTHORIZATION;
} }
try { try {
$this->capture_authorizations( ...$authorizations ); $this->capture_authorizations( ...$authorizations );
} catch ( Exception $exception ) { } catch ( Exception $exception ) {
$this->last_status = self::FAILED; $this->logger->error( 'Failed to capture authorization: ' . $exception->getMessage() );
return false; return self::FAILED;
} }
$this->last_status = self::SUCCESSFUL; return self::SUCCESSFUL;
return true;
}
/**
* Returns the last status.
*
* @return string
*/
public function last_status(): string {
return $this->last_status;
} }
/** /**
@ -141,17 +136,6 @@ class AuthorizedPaymentsProcessor {
return $authorizations; 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. * Captures the authorizations.
* *
@ -171,11 +155,38 @@ class AuthorizedPaymentsProcessor {
* @return Authorization[] * @return Authorization[]
*/ */
private function authorizations_to_capture( Authorization ...$authorizations ): array { 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( return array_filter(
$authorizations, $authorizations,
static function ( Authorization $authorization ): bool { static function ( Authorization $authorization ) use ( $statuses ): bool {
return $authorization->status()->is( AuthorizationStatus::CREATED ) $status = $authorization->status();
|| $authorization->status()->is( AuthorizationStatus::PENDING ); return in_array( $status->name(), $statuses, true );
} }
); );
} }

View file

@ -188,7 +188,7 @@ class OrderProcessor {
$wc_order->payment_complete(); $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( $wc_order->add_order_note(
__( 'Payment successfully captured.', 'woocommerce-paypal-payments' ) __( 'Payment successfully captured.', 'woocommerce-paypal-payments' )
); );

View file

@ -9,10 +9,15 @@ declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\Processor; namespace WooCommerce\PayPalCommerce\WcGateway\Processor;
use Exception;
use Psr\Log\LoggerInterface;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Amount; 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\Money;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Payments;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund; use WooCommerce\PayPalCommerce\ApiClient\Entity\Refund;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway; use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
@ -22,6 +27,10 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
*/ */
class RefundProcessor { class RefundProcessor {
private const REFUND_MODE_REFUND = 'refund';
private const REFUND_MODE_VOID = 'void';
private const REFUND_MODE_UNKNOWN = 'unknown';
/** /**
* The order endpoint. * The order endpoint.
* *
@ -36,16 +45,25 @@ class RefundProcessor {
*/ */
private $payments_endpoint; private $payments_endpoint;
/**
* The logger.
*
* @var LoggerInterface
*/
private $logger;
/** /**
* RefundProcessor constructor. * RefundProcessor constructor.
* *
* @param OrderEndpoint $order_endpoint The order endpoint. * @param OrderEndpoint $order_endpoint The order endpoint.
* @param PaymentsEndpoint $payments_endpoint The payments 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->order_endpoint = $order_endpoint;
$this->payments_endpoint = $payments_endpoint; $this->payments_endpoint = $payments_endpoint;
$this->logger = $logger;
} }
/** /**
@ -56,44 +74,115 @@ class RefundProcessor {
* @param string $reason The reason for the refund. * @param string $reason The reason for the refund.
* *
* @return bool * @return bool
*
* @phpcs:ignore Squiz.Commenting.FunctionCommentThrowTag.Missing
*/ */
public function process( \WC_Order $wc_order, float $amount = null, string $reason = '' ) : bool { 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 { try {
$order = $this->order_endpoint->order( $order_id ); $order_id = $wc_order->get_meta( PayPalGateway::ORDER_ID_META_KEY );
if ( ! $order ) { if ( ! $order_id ) {
return false; throw new RuntimeException( 'PayPal order ID not found in meta.' );
} }
$order = $this->order_endpoint->order( $order_id );
$purchase_units = $order->purchase_units(); $purchase_units = $order->purchase_units();
if ( ! $purchase_units ) { if ( ! $purchase_units ) {
return false; throw new RuntimeException( 'No purchase units.' );
} }
$payments = $purchase_units[0]->payments(); $payments = $purchase_units[0]->payments();
if ( ! $payments ) { if ( ! $payments ) {
return false; throw new RuntimeException( 'No payments.' );
}
$captures = $payments->captures();
if ( ! $captures ) {
return false;
} }
$capture = $captures[0]; $this->logger->debug(
$refund = new Refund( sprintf(
$capture, 'Trying to refund/void order %1$s, payments: %2$s.',
$capture->invoice_id(), $order->id(),
$reason, wp_json_encode( $payments->to_array() )
new Amount(
new Money( $amount, $wc_order->get_currency() )
) )
); );
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; 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 );
}
} }

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\ApiClient\Endpoint; namespace WooCommerce\PayPalCommerce\ApiClient\Endpoint;
use Psr\Log\NullLogger;
use Requests_Utility_CaseInsensitiveDictionary; use Requests_Utility_CaseInsensitiveDictionary;
use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer; use WooCommerce\PayPalCommerce\ApiClient\Authentication\Bearer;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization;
@ -235,10 +236,6 @@ class PaymentsEndpointTest extends TestCase
$authorizationFactory = Mockery::mock(AuthorizationFactory::class); $authorizationFactory = Mockery::mock(AuthorizationFactory::class);
$logger = Mockery::mock(LoggerInterface::class);
$logger->expects('log');
$logger->expects('debug');
$headers = Mockery::mock(Requests_Utility_CaseInsensitiveDictionary::class); $headers = Mockery::mock(Requests_Utility_CaseInsensitiveDictionary::class);
$headers->shouldReceive('getAll'); $headers->shouldReceive('getAll');
$rawResponse = [ $rawResponse = [
@ -250,7 +247,7 @@ class PaymentsEndpointTest extends TestCase
$host, $host,
$bearer, $bearer,
$authorizationFactory, $authorizationFactory,
$logger new NullLogger()
); );
expect('wp_remote_get')->andReturn($rawResponse); expect('wp_remote_get')->andReturn($rawResponse);
@ -281,15 +278,11 @@ class PaymentsEndpointTest extends TestCase
'headers' => $headers, 'headers' => $headers,
]; ];
$logger = Mockery::mock(LoggerInterface::class);
$logger->expects('log');
$logger->expects('debug');
$testee = new PaymentsEndpoint( $testee = new PaymentsEndpoint(
$host, $host,
$bearer, $bearer,
$authorizationFactory, $authorizationFactory,
$logger new NullLogger()
); );
expect('wp_remote_get')->andReturn($rawResponse); expect('wp_remote_get')->andReturn($rawResponse);

View file

@ -227,10 +227,7 @@ class WcGatewayTest extends TestCase
$authorizedPaymentsProcessor $authorizedPaymentsProcessor
->expects('process') ->expects('process')
->with($wcOrder) ->with($wcOrder)
->andReturnTrue(); ->andReturn(AuthorizedPaymentsProcessor::SUCCESSFUL);
$authorizedPaymentsProcessor
->expects('last_status')
->andReturn(AuthorizedPaymentsProcessor::SUCCESSFUL);
$authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class); $authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class);
$authorizedOrderActionNotice $authorizedOrderActionNotice
->expects('display_message') ->expects('display_message')
@ -286,10 +283,7 @@ class WcGatewayTest extends TestCase
$authorizedPaymentsProcessor $authorizedPaymentsProcessor
->expects('process') ->expects('process')
->with($wcOrder) ->with($wcOrder)
->andReturnFalse(); ->andReturn(AuthorizedPaymentsProcessor::ALREADY_CAPTURED);
$authorizedPaymentsProcessor
->shouldReceive('last_status')
->andReturn(AuthorizedPaymentsProcessor::ALREADY_CAPTURED);
$authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class); $authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class);
$authorizedOrderActionNotice $authorizedOrderActionNotice
->expects('display_message') ->expects('display_message')
@ -338,10 +332,7 @@ class WcGatewayTest extends TestCase
$authorizedPaymentsProcessor $authorizedPaymentsProcessor
->expects('process') ->expects('process')
->with($wcOrder) ->with($wcOrder)
->andReturnFalse(); ->andReturn($lastStatus);
$authorizedPaymentsProcessor
->shouldReceive('last_status')
->andReturn($lastStatus);
$authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class); $authorizedOrderActionNotice = Mockery::mock(AuthorizeOrderActionNotice::class);
$authorizedOrderActionNotice $authorizedOrderActionNotice
->expects('display_message') ->expects('display_message')

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Processor; namespace WooCommerce\PayPalCommerce\WcGateway\Processor;
use Psr\Log\NullLogger;
use WC_Order;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\OrderEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\PaymentsEndpoint;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization; use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization;
@ -17,186 +19,145 @@ use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use Mockery; use Mockery;
class AuthorizedPaymentsProcessorTest extends TestCase class AuthorizedPaymentsProcessorTest extends TestCase
{ {
private $wcOrder;
private $paypalOrderId = 'abc';
public function testDefault() { private $authorizationId = 'qwe';
$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);
$wcOrder = Mockery::mock(\WC_Order::class); private $paypalOrder;
$wcOrder
->expects('get_meta') private $orderEndpoint;
->with(PayPalGateway::ORDER_ID_META_KEY)
->andReturn($orderId); private $paymentsEndpoint;
$this->assertTrue($testee->process($wcOrder));
$this->assertEquals(AuthorizedPaymentsProcessor::SUCCESSFUL, $testee->last_status()); 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() { public function testInaccessible() {
$orderId = 'abc'; $this->orderEndpoint
$orderEndpoint = Mockery::mock(OrderEndpoint::class);
$orderEndpoint
->expects('order') ->expects('order')
->with($orderId) ->with($this->paypalOrderId)
->andThrow(RuntimeException::class); ->andThrow(RuntimeException::class);
$paymentsEndpoint = Mockery::mock(PaymentsEndpoint::class);
$testee = new AuthorizedPaymentsProcessor($orderEndpoint, $paymentsEndpoint);
$wcOrder = Mockery::mock(\WC_Order::class); $this->assertEquals(AuthorizedPaymentsProcessor::INACCESSIBLE, $this->testee->process($this->wcOrder));
$wcOrder
->expects('get_meta')
->with(PayPalGateway::ORDER_ID_META_KEY)
->andReturn($orderId);
$this->assertFalse($testee->process($wcOrder));
$this->assertEquals(AuthorizedPaymentsProcessor::INACCESSIBLE, $testee->last_status());
} }
public function testNotFound() { public function testNotFound() {
$orderId = 'abc'; $this->orderEndpoint
$orderEndpoint = Mockery::mock(OrderEndpoint::class);
$orderEndpoint
->expects('order') ->expects('order')
->with($orderId) ->with($this->paypalOrderId)
->andThrow(new RuntimeException("text", 404)); ->andThrow(new RuntimeException('text', 404));
$paymentsEndpoint = Mockery::mock(PaymentsEndpoint::class);
$testee = new AuthorizedPaymentsProcessor($orderEndpoint, $paymentsEndpoint);
$wcOrder = Mockery::mock(\WC_Order::class); $this->assertEquals(AuthorizedPaymentsProcessor::NOT_FOUND, $this->testee->process($this->wcOrder));
$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());
} }
public function testCaptureFails() { public function testCaptureFails() {
$orderId = 'abc'; $this->paymentsEndpoint
$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') ->expects('capture')
->with($authorizationId) ->with($this->authorizationId)
->andThrow(RuntimeException::class); ->andThrow(RuntimeException::class);
$testee = new AuthorizedPaymentsProcessor($orderEndpoint, $paymentsEndpoint);
$wcOrder = Mockery::mock(\WC_Order::class); $this->assertEquals(AuthorizedPaymentsProcessor::FAILED, $this->testee->process($this->wcOrder));
$wcOrder
->expects('get_meta')
->with(PayPalGateway::ORDER_ID_META_KEY)
->andReturn($orderId);
$this->assertFalse($testee->process($wcOrder));
$this->assertEquals(AuthorizedPaymentsProcessor::FAILED, $testee->last_status());
} }
public function testAllAreCaptured() { public function testAlreadyCaptured() {
$orderId = 'abc'; $this->paypalOrder = $this->createPaypalOrder([$this->createAuthorization($this->authorizationId, AuthorizationStatus::CAPTURED)]);
$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);
$wcOrder = Mockery::mock(\WC_Order::class); $this->assertEquals(AuthorizedPaymentsProcessor::ALREADY_CAPTURED, $this->testee->process($this->wcOrder));
$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());
} }
}
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;
}
}