From 22945c4df51bb0f5fbee6e37211451d22d4c1237 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 9 Oct 2024 10:30:36 +0300 Subject: [PATCH] Add void button --- .../resources/js/void-button.js | 53 +++++ modules/ppcp-wc-gateway/services.php | 41 +++- .../src/Assets/VoidButtonAssets.php | 171 +++++++++++++++ .../src/Endpoint/VoidOrderEndpoint.php | 198 ++++++++++++++++++ .../src/Processor/RefundProcessor.php | 40 ++-- .../ppcp-wc-gateway/src/WCGatewayModule.php | 32 +++ modules/ppcp-wc-gateway/webpack.config.js | 1 + 7 files changed, 513 insertions(+), 23 deletions(-) create mode 100644 modules/ppcp-wc-gateway/resources/js/void-button.js create mode 100644 modules/ppcp-wc-gateway/src/Assets/VoidButtonAssets.php create mode 100644 modules/ppcp-wc-gateway/src/Endpoint/VoidOrderEndpoint.php diff --git a/modules/ppcp-wc-gateway/resources/js/void-button.js b/modules/ppcp-wc-gateway/resources/js/void-button.js new file mode 100644 index 000000000..55e06ca51 --- /dev/null +++ b/modules/ppcp-wc-gateway/resources/js/void-button.js @@ -0,0 +1,53 @@ +import { + hide, + show, +} from '../../../ppcp-button/resources/js/modules/Helper/Hiding'; + +document.addEventListener( 'DOMContentLoaded', function () { + const refundButton = document.querySelector( 'button.refund-items' ); + if ( ! refundButton ) { + return; + } + + refundButton.insertAdjacentHTML( + 'afterend', + `` + ); + + hide( refundButton ); + + const voidButton = document.querySelector( '#pcpVoid' ); + + voidButton.addEventListener( 'click', async () => { + if ( ! window.confirm( PcpVoidButton.popup_text ) ) { + return; + } + + voidButton.setAttribute( 'disabled', 'disabled' ); + + const res = await fetch( PcpVoidButton.ajax.void.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + body: JSON.stringify( { + nonce: PcpVoidButton.ajax.void.nonce, + wc_order_id: PcpVoidButton.wc_order_id, + } ), + } ); + + const data = await res.json(); + + if ( ! data.success ) { + hide( voidButton ); + show( refundButton ); + + alert( PcpVoidButton.error_text ); + + throw Error( data.data.message ); + } + + location.reload(); + } ); +} ); diff --git a/modules/ppcp-wc-gateway/services.php b/modules/ppcp-wc-gateway/services.php index 9ed1a83a8..da397640d 100644 --- a/modules/ppcp-wc-gateway/services.php +++ b/modules/ppcp-wc-gateway/services.php @@ -25,8 +25,10 @@ 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\Assets\VoidButtonAssets; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\CaptureCardPayment; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\VoidOrderEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Helper\CartCheckoutDetector; use WooCommerce\PayPalCommerce\WcGateway\Helper\FeesUpdater; use WooCommerce\PayPalCommerce\WcGateway\Settings\WcTasks\Factory\SimpleRedirectTaskFactory; @@ -46,7 +48,6 @@ use WooCommerce\PayPalCommerce\WcGateway\Checkout\DisableGateways; use WooCommerce\PayPalCommerce\WcGateway\Cli\SettingsCommand; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\ReturnUrlEndpoint; use WooCommerce\PayPalCommerce\WcGateway\FraudNet\FraudNet; -use WooCommerce\PayPalCommerce\WcGateway\FraudNet\FraudNetSessionId; use WooCommerce\PayPalCommerce\WcGateway\FraudNet\FraudNetSourceWebsiteId; use WooCommerce\PayPalCommerce\WcGateway\FundingSource\FundingSourceRenderer; use WooCommerce\PayPalCommerce\WcGateway\Gateway\CardButtonGateway; @@ -514,12 +515,20 @@ return array( ); }, 'wcgateway.processor.refunds' => static function ( ContainerInterface $container ): RefundProcessor { - $order_endpoint = $container->get( 'api.endpoint.order' ); - $payments_endpoint = $container->get( 'api.endpoint.payments' ); - $refund_fees_updater = $container->get( 'wcgateway.helper.refund-fees-updater' ); - $prefix = $container->get( 'api.prefix' ); - $logger = $container->get( 'woocommerce.logger.woocommerce' ); - return new RefundProcessor( $order_endpoint, $payments_endpoint, $refund_fees_updater, $prefix, $logger ); + return new RefundProcessor( + $container->get( 'api.endpoint.order' ), + $container->get( 'api.endpoint.payments' ), + $container->get( 'wcgateway.helper.refund-fees-updater' ), + $container->get( 'wcgateway.allowed_refund_payment_methods' ), + $container->get( 'api.prefix' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, + 'wcgateway.allowed_refund_payment_methods' => static function ( ContainerInterface $container ): array { + return apply_filters( + 'woocommerce_paypal_payments_allowed_refund_payment_methods', + array( PayPalGateway::ID, CreditCardGateway::ID, CardButtonGateway::ID, PayUponInvoiceGateway::ID ) + ); }, 'wcgateway.processor.authorized-payments' => static function ( ContainerInterface $container ): AuthorizedPaymentsProcessor { $order_endpoint = $container->get( 'api.endpoint.order' ); @@ -1914,4 +1923,22 @@ return array( return $simple_redirect_tasks; }, + + 'wcgateway.void-button.assets' => function( ContainerInterface $container ) : VoidButtonAssets { + return new VoidButtonAssets( + $container->get( 'wcgateway.url' ), + $container->get( 'ppcp.asset-version' ), + $container->get( 'api.endpoint.order' ), + $container->get( 'wcgateway.processor.refunds' ), + $container->get( 'wcgateway.allowed_refund_payment_methods' ) + ); + }, + 'wcgateway.void-button.endpoint' => function( ContainerInterface $container ) : VoidOrderEndpoint { + return new VoidOrderEndpoint( + $container->get( 'button.request-data' ), + $container->get( 'api.endpoint.order' ), + $container->get( 'wcgateway.processor.refunds' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, ); diff --git a/modules/ppcp-wc-gateway/src/Assets/VoidButtonAssets.php b/modules/ppcp-wc-gateway/src/Assets/VoidButtonAssets.php new file mode 100644 index 000000000..9a2435449 --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Assets/VoidButtonAssets.php @@ -0,0 +1,171 @@ +module_url = $module_url; + $this->version = $version; + $this->order_endpoint = $order_endpoint; + $this->refund_processor = $refund_processor; + $this->allowed_refund_payment_methods = $allowed_refund_payment_methods; + } + + /** + * Checks if should register assets on the current page. + */ + public function should_register(): bool { + if ( ! is_admin() || wp_doing_ajax() ) { + return false; + } + + global $theorder; + + if ( ! $theorder instanceof WC_Order ) { + return false; + } + + $current_screen = get_current_screen(); + if ( ! $current_screen instanceof WP_Screen ) { + return false; + } + if ( $current_screen->post_type !== 'shop_order' ) { + return false; + } + + if ( ! in_array( $theorder->get_payment_method(), $this->allowed_refund_payment_methods, true ) ) { + return false; + } + + // Skip if there are refunds already, it is probably not voidable anymore + void cannot be partial. + if ( $theorder->get_remaining_refund_amount() !== $theorder->get_total() ) { + return false; + } + + $order_id = $theorder->get_meta( PayPalGateway::ORDER_ID_META_KEY ); + if ( ! $order_id ) { + return false; + } + + try { + $order = $this->order_endpoint->order( $order_id ); + + if ( $this->refund_processor->determine_refund_mode( $order ) !== RefundProcessor::REFUND_MODE_VOID ) { + return false; + } + } catch ( Exception $exception ) { + return false; + } + + return true; + } + + /** + * Enqueues the assets. + */ + public function register(): void { + global $theorder; + assert( $theorder instanceof WC_Order ); + + wp_enqueue_script( + 'ppcp-void-button', + trailingslashit( $this->module_url ) . 'assets/js/void-button.js', + array(), + $this->version, + true + ); + + wp_localize_script( + 'ppcp-void-button', + 'PcpVoidButton', + array( + 'button_text' => __( 'Void authorization', 'woocommerce-paypal-payments' ), + 'popup_text' => __( + 'After voiding an authorized transaction, you cannot capture any funds associated with that transaction, and the funds are returned to the customer. Voiding an authorization cancels the entire open amount.', + 'woocommerce-paypal-payments' + ), + 'error_text' => __( + 'The operation failed. Use the Refund button if the funds were already captured.', + 'woocommerce-paypal-payments' + ), + 'wc_order_id' => $theorder->get_id(), + 'ajax' => array( + 'void' => array( + 'endpoint' => WC_AJAX::get_endpoint( VoidOrderEndpoint::ENDPOINT ), + 'nonce' => wp_create_nonce( VoidOrderEndpoint::nonce() ), + ), + ), + ), + ); + } +} diff --git a/modules/ppcp-wc-gateway/src/Endpoint/VoidOrderEndpoint.php b/modules/ppcp-wc-gateway/src/Endpoint/VoidOrderEndpoint.php new file mode 100644 index 000000000..c929044da --- /dev/null +++ b/modules/ppcp-wc-gateway/src/Endpoint/VoidOrderEndpoint.php @@ -0,0 +1,198 @@ +request_data = $request_data; + $this->order_endpoint = $order_endpoint; + $this->refund_processor = $refund_processor; + $this->logger = $logger; + } + + /** + * Returns the nonce. + */ + public static function nonce(): string { + return self::ENDPOINT; + } + + /** + * Handles the incoming request. + */ + public function handle_request(): void { + $request = $this->request_data->read_request( self::nonce() ); + + if ( ! current_user_can( 'manage_woocommerce' ) ) { + wp_send_json_error( + array( + 'message' => 'Invalid request.', + ) + ); + return; + } + + $wc_order_id = (int) $request['wc_order_id']; + + $wc_order = wc_get_order( $wc_order_id ); + if ( ! $wc_order instanceof WC_Order ) { + wp_send_json_error( + array( + 'message' => 'WC order not found.', + ) + ); + return; + } + $order_id = $wc_order->get_meta( PayPalGateway::ORDER_ID_META_KEY ); + if ( ! $order_id ) { + wp_send_json_error( + array( + 'message' => 'PayPal order ID not found in meta.', + ) + ); + return; + } + + try { + $order = $this->order_endpoint->order( $order_id ); + + $this->refund_processor->void( $order ); + + $this->make_refunded( $wc_order ); + } catch ( Exception $exception ) { + wp_send_json_error( + array( + 'message' => 'Void failed. ' . $exception->getMessage(), + ) + ); + $this->logger->error( 'Void failed. ' . $exception->getMessage() ); + return; + } + + wp_send_json_success(); + } + + /** + * Returns the list of items for the wc_create_refund data, + * making all items refunded (max qty, total, taxes). + * + * @param WC_Order $wc_order The WC order. + */ + protected function refund_items( WC_Order $wc_order ): array { + $refunded_items = array(); + foreach ( $wc_order->get_items( array( 'line_item', 'fee', 'shipping' ) ) as $item ) { + // Some methods like get_taxes() are not defined in WC_Order_Item. + if ( + ! $item instanceof WC_Order_Item_Product + && ! $item instanceof WC_Order_Item_Fee + && ! $item instanceof WC_Order_Item_Shipping + ) { + continue; + } + + $taxes = array(); + $item_taxes = $item->get_taxes(); + /** + * The type is not really guaranteed in the code. + * + * @psalm-suppress RedundantConditionGivenDocblockType + */ + if ( is_array( $item_taxes ) && isset( $item_taxes['total'] ) ) { + $taxes = $item_taxes['total']; + } + + $refunded_items[ $item->get_id() ] = array( + 'qty' => $item->get_type() === 'line_item' ? $item->get_quantity() : 0, + 'refund_total' => $item->get_total(), + 'refund_tax' => $taxes, + ); + } + return $refunded_items; + } + + /** + * Creates a full refund. + * + * @param WC_Order $wc_order The WC order. + */ + private function make_refunded( WC_Order $wc_order ): void { + wc_create_refund( + array( + 'amount' => $wc_order->get_total(), + 'reason' => __( 'Voided authorization', 'woocommerce-paypal-payments' ), + 'order_id' => $wc_order->get_id(), + 'line_items' => $this->refund_items( $wc_order ), + 'refund_payment' => false, + 'restock_items' => (bool) apply_filters( 'woocommerce_paypal_payments_void_restock_items', true ), + ) + ); + + $wc_order->set_status( 'refunded' ); + } +} + diff --git a/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php b/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php index 25e4c5eed..523fcf00e 100644 --- a/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php +++ b/modules/ppcp-wc-gateway/src/Processor/RefundProcessor.php @@ -33,9 +33,9 @@ use WooCommerce\PayPalCommerce\WcGateway\Helper\RefundFeesUpdater; class RefundProcessor { use RefundMetaTrait; - private const REFUND_MODE_REFUND = 'refund'; - private const REFUND_MODE_VOID = 'void'; - private const REFUND_MODE_UNKNOWN = 'unknown'; + public const REFUND_MODE_REFUND = 'refund'; + public const REFUND_MODE_VOID = 'void'; + public const REFUND_MODE_UNKNOWN = 'unknown'; /** * The order endpoint. @@ -72,12 +72,20 @@ class RefundProcessor { */ private $refund_fees_updater; + /** + * The methods that can be refunded. + * + * @var array + */ + private $allowed_refund_payment_methods; + /** * RefundProcessor constructor. * * @param OrderEndpoint $order_endpoint The order endpoint. * @param PaymentsEndpoint $payments_endpoint The payments endpoint. * @param RefundFeesUpdater $refund_fees_updater The refund fees updater. + * @param array $allowed_refund_payment_methods The methods that can be refunded. * @param string $prefix The prefix. * @param LoggerInterface $logger The logger. */ @@ -85,15 +93,17 @@ class RefundProcessor { OrderEndpoint $order_endpoint, PaymentsEndpoint $payments_endpoint, RefundFeesUpdater $refund_fees_updater, + array $allowed_refund_payment_methods, string $prefix, LoggerInterface $logger ) { - $this->order_endpoint = $order_endpoint; - $this->payments_endpoint = $payments_endpoint; - $this->refund_fees_updater = $refund_fees_updater; - $this->prefix = $prefix; - $this->logger = $logger; + $this->order_endpoint = $order_endpoint; + $this->payments_endpoint = $payments_endpoint; + $this->refund_fees_updater = $refund_fees_updater; + $this->allowed_refund_payment_methods = $allowed_refund_payment_methods; + $this->prefix = $prefix; + $this->logger = $logger; } /** @@ -109,11 +119,7 @@ class RefundProcessor { */ public function process( WC_Order $wc_order, float $amount = null, string $reason = '' ) : bool { try { - $allowed_refund_payment_methods = apply_filters( - 'woocommerce_paypal_payments_allowed_refund_payment_methods', - array( PayPalGateway::ID, CreditCardGateway::ID, CardButtonGateway::ID, PayUponInvoiceGateway::ID ) - ); - if ( ! in_array( $wc_order->get_payment_method(), $allowed_refund_payment_methods, true ) ) { + if ( ! in_array( $wc_order->get_payment_method(), $this->allowed_refund_payment_methods, true ) ) { return true; } @@ -134,7 +140,7 @@ class RefundProcessor { ) ); - $mode = $this->determine_refund_mode( $payments ); + $mode = $this->determine_refund_mode( $order ); switch ( $mode ) { case self::REFUND_MODE_REFUND: @@ -226,11 +232,13 @@ class RefundProcessor { /** * Determines the refunding mode. * - * @param Payments $payments The order payments state. + * @param Order $order The order. * * @return string One of the REFUND_MODE_ constants. */ - private function determine_refund_mode( Payments $payments ): string { + public function determine_refund_mode( Order $order ): string { + $payments = $this->get_payments( $order ); + $authorizations = $payments->authorizations(); if ( $authorizations ) { foreach ( $authorizations as $authorization ) { diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php index 2471af4e7..25f36d23e 100644 --- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php +++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php @@ -21,7 +21,9 @@ use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExecutableModule use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ExtendingModule; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ModuleClassNameIdTrait; use WooCommerce\PayPalCommerce\Vendor\Inpsyde\Modularity\Module\ServiceModule; +use WooCommerce\PayPalCommerce\WcGateway\Assets\VoidButtonAssets; use WooCommerce\PayPalCommerce\WcGateway\Endpoint\RefreshFeatureStatusEndpoint; +use WooCommerce\PayPalCommerce\WcGateway\Endpoint\VoidOrderEndpoint; use WooCommerce\PayPalCommerce\WcGateway\Processor\CreditCardOrderInfoHandlingTrait; use WC_Order; use WooCommerce\PayPalCommerce\AdminNotices\Repository\Repository; @@ -90,6 +92,7 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul $this->register_columns( $c ); $this->register_checkout_paypal_address_preset( $c ); $this->register_wc_tasks( $c ); + $this->register_void_button( $c ); add_action( 'woocommerce_sections_checkout', @@ -870,4 +873,33 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul }, ); } + + /** + * Registers the assets and ajax endpoint for the void button. + * + * @param ContainerInterface $container The container. + */ + protected function register_void_button( ContainerInterface $container ): void { + add_action( + 'admin_enqueue_scripts', + static function () use ( $container ) { + $assets = $container->get( 'wcgateway.void-button.assets' ); + assert( $assets instanceof VoidButtonAssets ); + + if ( $assets->should_register() ) { + $assets->register(); + } + } + ); + + add_action( + 'wc_ajax_' . VoidOrderEndpoint::ENDPOINT, + static function () use ( $container ) { + $endpoint = $container->get( 'wcgateway.void-button.endpoint' ); + assert( $endpoint instanceof VoidOrderEndpoint ); + + $endpoint->handle_request(); + } + ); + } } diff --git a/modules/ppcp-wc-gateway/webpack.config.js b/modules/ppcp-wc-gateway/webpack.config.js index 394e549fe..196b8b2b1 100644 --- a/modules/ppcp-wc-gateway/webpack.config.js +++ b/modules/ppcp-wc-gateway/webpack.config.js @@ -10,6 +10,7 @@ module.exports = { 'gateway-settings': path.resolve('./resources/js/gateway-settings.js'), 'fraudnet': path.resolve('./resources/js/fraudnet.js'), 'oxxo': path.resolve('./resources/js/oxxo.js'), + 'void-button': path.resolve('./resources/js/void-button.js'), 'gateway-settings-style': path.resolve('./resources/css/gateway-settings.scss'), 'common-style': path.resolve('./resources/css/common.scss'), },