Merge pull request #2062 from woocommerce/PCP-2143-reauthorize-authorized-payments

reauthorize authorized payments (2143)
This commit is contained in:
Emili Castells 2024-03-11 17:24:06 +01:00 committed by GitHub
commit 0ce740ba62
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 354 additions and 9 deletions

View file

@ -87,6 +87,32 @@ function ppcp_capture_order( WC_Order $wc_order ): void {
}
}
/**
* Reauthorizes the PayPal order.
*
* @param WC_Order $wc_order The WC order.
* @throws InvalidArgumentException When the order cannot be captured.
* @throws Exception When the operation fails.
*/
function ppcp_reauthorize_order( WC_Order $wc_order ): void {
$intent = strtoupper( (string) $wc_order->get_meta( PayPalGateway::INTENT_META_KEY ) );
if ( $intent !== 'AUTHORIZE' ) {
throw new InvalidArgumentException( 'Only orders with "authorize" intent can be reauthorized.' );
}
$captured = wc_string_to_bool( $wc_order->get_meta( AuthorizedPaymentsProcessor::CAPTURED_META_KEY ) );
if ( $captured ) {
throw new InvalidArgumentException( 'The order is already captured.' );
}
$authorized_payment_processor = PPCP::container()->get( 'wcgateway.processor.authorized-payments' );
assert( $authorized_payment_processor instanceof AuthorizedPaymentsProcessor );
if ( $authorized_payment_processor->reauthorize_payment( $wc_order ) !== AuthorizedPaymentsProcessor::SUCCESSFUL ) {
throw new RuntimeException( $authorized_payment_processor->reauthorization_failure_reason() ?: 'Reauthorization failed.' );
}
}
/**
* Refunds the PayPal order.
* Note that you can use wc_refund_payment() to trigger the refund in WC and PayPal.

View file

@ -9,6 +9,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\AdminNotices;
use WooCommerce\PayPalCommerce\AdminNotices\Entity\Message;
use WooCommerce\PayPalCommerce\AdminNotices\Repository\Repository;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Container\ServiceProvider;
use WooCommerce\PayPalCommerce\Vendor\Dhii\Modular\Module\ModuleInterface;
use WooCommerce\PayPalCommerce\Vendor\Interop\Container\ServiceProviderInterface;
@ -40,6 +42,34 @@ class AdminNotices implements ModuleInterface {
$renderer->render();
}
);
add_action(
Repository::NOTICES_FILTER,
/**
* Adds persisted notices to the notices array.
*
* @param array $notices The notices.
* @return array
*
* @psalm-suppress MissingClosureParamType
*/
function ( $notices ) use ( $c ) {
if ( ! is_array( $notices ) ) {
return $notices;
}
$admin_notices = $c->get( 'admin-notices.repository' );
assert( $admin_notices instanceof Repository );
$persisted_notices = $admin_notices->get_persisted_and_clear();
if ( $persisted_notices ) {
$notices = array_merge( $notices, $persisted_notices );
}
return $notices;
}
);
}
/**

View file

@ -92,4 +92,18 @@ class Message {
public function wrapper(): string {
return $this->wrapper;
}
/**
* Returns the object as array.
*
* @return array
*/
public function to_array(): array {
return array(
'type' => $this->type,
'message' => $this->message,
'dismissable' => $this->dismissable,
'wrapper' => $this->wrapper,
);
}
}

View file

@ -17,6 +17,7 @@ use WooCommerce\PayPalCommerce\AdminNotices\Entity\Message;
class Repository implements RepositoryInterface {
const NOTICES_FILTER = 'ppcp.admin-notices.current-notices';
const PERSISTED_NOTICES_OPTION = 'woocommerce_ppcp-admin-notices';
/**
* Returns the current messages.
@ -37,4 +38,40 @@ class Repository implements RepositoryInterface {
}
);
}
/**
* Adds a message to persist between page reloads.
*
* @param Message $message The message.
* @return void
*/
public function persist( Message $message ): void {
$persisted_notices = get_option( self::PERSISTED_NOTICES_OPTION ) ?: array();
$persisted_notices[] = $message->to_array();
update_option( self::PERSISTED_NOTICES_OPTION, $persisted_notices );
}
/**
* Adds a message to persist between page reloads.
*
* @return array|Message[]
*/
public function get_persisted_and_clear(): array {
$notices = array();
$persisted_data = get_option( self::PERSISTED_NOTICES_OPTION ) ?: array();
foreach ( $persisted_data as $notice_data ) {
$notices[] = new Message(
(string) ( $notice_data['message'] ?? '' ),
(string) ( $notice_data['type'] ?? '' ),
(bool) ( $notice_data['dismissable'] ?? true ),
(string) ( $notice_data['wrapper'] ?? '' )
);
}
update_option( self::PERSISTED_NOTICES_OPTION, array(), true );
return $notices;
}
}

View file

@ -193,6 +193,53 @@ class PaymentsEndpoint {
return $this->capture_factory->from_paypal_response( $json );
}
/**
* Reauthorizes an order.
*
* @param string $authorization_id The id.
* @param Money|null $amount The amount to capture. If not specified, the whole authorized amount is captured.
*
* @return string
* @throws RuntimeException If the request fails.
* @throws PayPalApiException If the request fails.
*/
public function reauthorize( string $authorization_id, ?Money $amount = null ) : string {
$bearer = $this->bearer->bearer();
$url = trailingslashit( $this->host ) . 'v2/payments/authorizations/' . $authorization_id . '/reauthorize';
$data = array();
if ( $amount ) {
$data['amount'] = $amount->to_array();
}
$args = array(
'method' => 'POST',
'headers' => array(
'Authorization' => 'Bearer ' . $bearer->token(),
'Content-Type' => 'application/json',
'Prefer' => 'return=representation',
),
'body' => wp_json_encode( $data, JSON_FORCE_OBJECT ),
);
$response = $this->request( $url, $args );
$json = json_decode( $response['body'] );
if ( is_wp_error( $response ) ) {
throw new RuntimeException( 'Could not reauthorize authorized payment.' );
}
$status_code = (int) wp_remote_retrieve_response_code( $response );
if ( 201 !== $status_code || ! is_object( $json ) ) {
throw new PayPalApiException(
$json,
$status_code
);
}
return $json->id;
}
/**
* Refunds a payment.
*

View file

@ -22,6 +22,7 @@ use WooCommerce\PayPalCommerce\Common\Pattern\SingletonDecorator;
use WooCommerce\PayPalCommerce\Onboarding\Environment;
use WooCommerce\PayPalCommerce\Onboarding\Render\OnboardingOptionsRenderer;
use WooCommerce\PayPalCommerce\Onboarding\State;
use WooCommerce\PayPalCommerce\WcGateway\Admin\RenderReauthorizeAction;
use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint;
use WooCommerce\PayPalCommerce\WcSubscriptions\Helper\SubscriptionHelper;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
@ -384,19 +385,25 @@ return array(
$notice = $container->get( 'wcgateway.notice.authorize-order-action' );
$settings = $container->get( 'wcgateway.settings' );
$subscription_helper = $container->get( 'wc-subscriptions.helper' );
$amount_factory = $container->get( 'api.factory.amount' );
return new AuthorizedPaymentsProcessor(
$order_endpoint,
$payments_endpoint,
$logger,
$notice,
$settings,
$subscription_helper
$subscription_helper,
$amount_factory
);
},
'wcgateway.admin.render-authorize-action' => static function ( ContainerInterface $container ): RenderAuthorizeAction {
$column = $container->get( 'wcgateway.admin.orders-payment-status-column' );
return new RenderAuthorizeAction( $column );
},
'wcgateway.admin.render-reauthorize-action' => static function ( ContainerInterface $container ): RenderReauthorizeAction {
$column = $container->get( 'wcgateway.admin.orders-payment-status-column' );
return new RenderReauthorizeAction( $column );
},
'wcgateway.admin.order-payment-status' => static function ( ContainerInterface $container ): PaymentStatusOrderDetail {
$column = $container->get( 'wcgateway.admin.orders-payment-status-column' );
return new PaymentStatusOrderDetail( $column );

View file

@ -9,9 +9,6 @@ declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\Admin;
use WooCommerce\PayPalCommerce\WcGateway\Gateway\PayPalGateway;
use WooCommerce\PayPalCommerce\WcGateway\Processor\AuthorizedPaymentsProcessor;
/**
* Class RenderAuthorizeAction
*/

View file

@ -0,0 +1,67 @@
<?php
/**
* Renders the order action "Reauthorize PayPal payment"
*
* @package WooCommerce\PayPalCommerce\WcGateway\Admin
*/
declare( strict_types=1 );
namespace WooCommerce\PayPalCommerce\WcGateway\Admin;
/**
* Class RenderReauthorizeAction
*/
class RenderReauthorizeAction {
/**
* 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 action into the $order_actions array based on the WooCommerce order.
*
* @param array $order_actions The actions to render into.
* @param \WC_Order $wc_order The order for which to render the action.
*
* @return array
*/
public function render( array $order_actions, \WC_Order $wc_order ) : array {
if ( ! $this->should_render_for_order( $wc_order ) ) {
return $order_actions;
}
$order_actions['ppcp_reauthorize_order'] = esc_html__(
'Reauthorize PayPal payment',
'woocommerce-paypal-payments'
);
return $order_actions;
}
/**
* Whether the action should be rendered for a certain WooCommerce order.
*
* @param \WC_Order $order The Woocommerce order.
*
* @return bool
*/
private function should_render_for_order( \WC_Order $order ) : bool {
$status = $order->get_status();
$not_allowed_statuses = array( 'refunded', 'cancelled', 'failed' );
return $this->column->should_render_for_order( $order ) &&
! $this->column->is_captured( $order ) &&
! in_array( $status, $not_allowed_statuses, true );
}
}

View file

@ -10,6 +10,8 @@ declare(strict_types=1);
namespace WooCommerce\PayPalCommerce\WcGateway\Processor;
use Exception;
use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException;
use WooCommerce\PayPalCommerce\ApiClient\Factory\AmountFactory;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use WC_Order;
@ -91,6 +93,20 @@ class AuthorizedPaymentsProcessor {
*/
private $subscription_helper;
/**
* The amount factory.
*
* @var AmountFactory
*/
private $amount_factory;
/**
* The reauthorization failure reason.
*
* @var string
*/
private $reauthorization_failure_reason = '';
/**
* AuthorizedPaymentsProcessor constructor.
*
@ -100,6 +116,7 @@ class AuthorizedPaymentsProcessor {
* @param AuthorizeOrderActionNotice $notice The notice.
* @param ContainerInterface $config The settings.
* @param SubscriptionHelper $subscription_helper The subscription helper.
* @param AmountFactory $amount_factory The amount factory.
*/
public function __construct(
OrderEndpoint $order_endpoint,
@ -107,7 +124,8 @@ class AuthorizedPaymentsProcessor {
LoggerInterface $logger,
AuthorizeOrderActionNotice $notice,
ContainerInterface $config,
SubscriptionHelper $subscription_helper
SubscriptionHelper $subscription_helper,
AmountFactory $amount_factory
) {
$this->order_endpoint = $order_endpoint;
@ -116,6 +134,7 @@ class AuthorizedPaymentsProcessor {
$this->notice = $notice;
$this->config = $config;
$this->subscription_helper = $subscription_helper;
$this->amount_factory = $amount_factory;
}
/**
@ -249,6 +268,67 @@ class AuthorizedPaymentsProcessor {
}
}
/**
* Reauthorizes an authorized payment for an WooCommerce order.
*
* @param WC_Order $wc_order The WooCommerce order.
*
* @return string The status or reauthorization id.
*/
public function reauthorize_payment( WC_Order $wc_order ): string {
$this->reauthorization_failure_reason = '';
try {
$order = $this->paypal_order_from_wc_order( $wc_order );
} catch ( Exception $exception ) {
$this->logger->error( 'Could not get PayPal order from WC order: ' . $exception->getMessage() );
if ( $exception->getCode() === 404 ) {
return self::NOT_FOUND;
}
return self::INACCESSIBLE;
}
$amount = $this->amount_factory->from_wc_order( $wc_order );
$authorizations = $this->all_authorizations( $order );
$uncaptured_authorizations = $this->authorizations_to_capture( ...$authorizations );
if ( ! $uncaptured_authorizations ) {
if ( $this->captured_authorizations( ...$authorizations ) ) {
$this->logger->info( 'Authorizations already captured.' );
return self::ALREADY_CAPTURED;
}
$this->logger->info( 'Bad authorization.' );
return self::BAD_AUTHORIZATION;
}
$authorization = end( $uncaptured_authorizations );
try {
$this->payments_endpoint->reauthorize( $authorization->id(), new Money( $amount->value(), $amount->currency_code() ) );
} catch ( PayPalApiException $exception ) {
$this->reauthorization_failure_reason = $exception->details()[0]->description ?? null;
$this->logger->error( 'Reauthorization failed: ' . $exception->name() . ' | ' . $this->reauthorization_failure_reason );
return self::FAILED;
} catch ( Exception $exception ) {
$this->logger->error( 'Failed to capture authorization: ' . $exception->getMessage() );
return self::FAILED;
}
return self::SUCCESSFUL;
}
/**
* The reason for a failed reauthorization.
*
* @return string
*/
public function reauthorization_failure_reason(): string {
return $this->reauthorization_failure_reason;
}
/**
* Voids authorizations for the given PayPal order.
*
@ -392,4 +472,5 @@ class AuthorizedPaymentsProcessor {
}
);
}
}

View file

@ -11,6 +11,7 @@ namespace WooCommerce\PayPalCommerce\WcGateway;
use Psr\Log\LoggerInterface;
use Throwable;
use WooCommerce\PayPalCommerce\AdminNotices\Entity\Message;
use WooCommerce\PayPalCommerce\ApiClient\Entity\Authorization;
use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException;
use WooCommerce\PayPalCommerce\ApiClient\Helper\Cache;
@ -614,13 +615,18 @@ class WCGatewayModule implements ModuleInterface {
return $order_actions;
}
$render = $container->get( 'wcgateway.admin.render-authorize-action' );
$render_reauthorize = $container->get( 'wcgateway.admin.render-reauthorize-action' );
$render_authorize = $container->get( 'wcgateway.admin.render-authorize-action' );
/**
* Renders the authorize action in the select field.
*
* @var RenderAuthorizeAction $render
*/
return $render->render( $order_actions, $theorder );
return $render_reauthorize->render(
$render_authorize->render( $order_actions, $theorder ),
$theorder
);
}
);
@ -637,6 +643,36 @@ class WCGatewayModule implements ModuleInterface {
$authorized_payments_processor->capture_authorized_payment( $wc_order );
}
);
add_action(
'woocommerce_order_action_ppcp_reauthorize_order',
static function ( WC_Order $wc_order ) use ( $container ) {
$admin_notices = $container->get( 'admin-notices.repository' );
assert( $admin_notices instanceof Repository );
/**
* The authorized payments processor.
*
* @var AuthorizedPaymentsProcessor $authorized_payments_processor
*/
$authorized_payments_processor = $container->get( 'wcgateway.processor.authorized-payments' );
if ( $authorized_payments_processor->reauthorize_payment( $wc_order ) !== AuthorizedPaymentsProcessor::SUCCESSFUL ) {
$message = sprintf(
'%1$s %2$s',
esc_html__( 'Reauthorization with PayPal failed: ', 'woocommerce-paypal-payments' ),
$authorized_payments_processor->reauthorization_failure_reason() ?: ''
);
$admin_notices->persist( new Message( $message, 'error' ) );
} else {
$admin_notices->persist( new Message( 'Payment reauthorized.', 'info' ) );
$wc_order->add_order_note(
__( 'Payment reauthorized.', 'woocommerce-paypal-payments' )
);
}
}
);
}
/**

View file

@ -5,6 +5,7 @@ namespace WooCommerce\PayPalCommerce\WcGateway\Processor;
use Mockery\MockInterface;
use WooCommerce\PayPalCommerce\ApiClient\Factory\AmountFactory;
use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface;
use Psr\Log\NullLogger;
use WC_Order;
@ -69,6 +70,7 @@ class AuthorizedPaymentsProcessorTest extends TestCase
$this->config = Mockery::mock(ContainerInterface::class);
$this->subscription_helper = Mockery::mock(SubscriptionHelper::class);
$this->amount_factory = Mockery::mock(AmountFactory::class);
$this->testee = new AuthorizedPaymentsProcessor(
$this->orderEndpoint,
@ -76,7 +78,8 @@ class AuthorizedPaymentsProcessorTest extends TestCase
new NullLogger(),
$this->notice,
$this->config,
$this->subscription_helper
$this->subscription_helper,
$this->amount_factory
);
}