From 9bc9c0305398721467bf4a3ac48fc172e04d4f48 Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Fri, 13 Sep 2024 15:35:13 +0200 Subject: [PATCH 01/28] Extract renewal handler logic to new class --- .../ppcp-paypal-subscriptions/services.php | 3 + .../src/RenewalHandler.php | 70 +++++++++++++++++++ modules/ppcp-webhooks/services.php | 2 +- .../src/Handler/PaymentSaleCompleted.php | 46 ++++++------ 4 files changed, 96 insertions(+), 25 deletions(-) create mode 100644 modules/ppcp-paypal-subscriptions/src/RenewalHandler.php diff --git a/modules/ppcp-paypal-subscriptions/services.php b/modules/ppcp-paypal-subscriptions/services.php index 4e64e678b..32f32b193 100644 --- a/modules/ppcp-paypal-subscriptions/services.php +++ b/modules/ppcp-paypal-subscriptions/services.php @@ -40,4 +40,7 @@ return array( dirname( realpath( __FILE__ ), 3 ) . '/woocommerce-paypal-payments.php' ); }, + 'paypal-subscriptions.renewal-handler' => static function ( ContainerInterface $container ): RenewalHandler { + return new RenewalHandler( $container->get( 'woocommerce.logger.woocommerce' ) ); + }, ); diff --git a/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php new file mode 100644 index 000000000..1261f418e --- /dev/null +++ b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php @@ -0,0 +1,70 @@ +logger = $logger; + } + + /** + * Process subscription renewal. + * + * @param WC_Subscription[] $subscriptions WC Subscriptions. + * @param string $transaction_id PayPal transaction ID. + * @return void + * @throws WC_Data_Exception If something goes wrong while setting payment method. + */ + public function process( array $subscriptions, string $transaction_id ): void { + foreach ( $subscriptions as $subscription ) { + $is_renewal = $subscription->get_meta( '_ppcp_is_subscription_renewal' ) ?? ''; + if ( $is_renewal ) { + $renewal_order = wcs_create_renewal_order( $subscription ); + if ( is_a( $renewal_order, WC_Order::class ) ) { + $renewal_order->set_payment_method( $subscription->get_payment_method() ); + $renewal_order->payment_complete(); + $this->update_transaction_id( $transaction_id, $renewal_order, $this->logger ); + break; + } + } + + $parent_order = wc_get_order( $subscription->get_parent() ); + if ( is_a( $parent_order, WC_Order::class ) ) { + $subscription->update_meta_data( '_ppcp_is_subscription_renewal', 'true' ); + $subscription->save_meta_data(); + $this->update_transaction_id( $transaction_id, $parent_order, $this->logger ); + } + } + } +} diff --git a/modules/ppcp-webhooks/services.php b/modules/ppcp-webhooks/services.php index 0f75a58a0..e179fd24a 100644 --- a/modules/ppcp-webhooks/services.php +++ b/modules/ppcp-webhooks/services.php @@ -99,7 +99,7 @@ return array( new VaultPaymentTokenCreated( $logger, $prefix, $authorized_payments_processor, $payment_token_factory, $payment_token_helper ), new VaultPaymentTokenDeleted( $logger ), new PaymentCapturePending( $logger ), - new PaymentSaleCompleted( $logger ), + new PaymentSaleCompleted( $logger, $container->get( 'paypal-subscriptions.renewal-handler' ) ), new PaymentSaleRefunded( $logger, $refund_fees_updater ), new BillingSubscriptionCancelled( $logger ), new BillingPlanPricingChangeActivated( $logger ), diff --git a/modules/ppcp-webhooks/src/Handler/PaymentSaleCompleted.php b/modules/ppcp-webhooks/src/Handler/PaymentSaleCompleted.php index 1679c46db..757cfd163 100644 --- a/modules/ppcp-webhooks/src/Handler/PaymentSaleCompleted.php +++ b/modules/ppcp-webhooks/src/Handler/PaymentSaleCompleted.php @@ -10,8 +10,8 @@ declare(strict_types=1); namespace WooCommerce\PayPalCommerce\Webhooks\Handler; use Psr\Log\LoggerInterface; -use WC_Order; -use WooCommerce\PayPalCommerce\WcGateway\Processor\TransactionIdHandlingTrait; +use WC_Data_Exception; +use WooCommerce\PayPalCommerce\PayPalSubscriptions\RenewalHandler; use WP_REST_Request; use WP_REST_Response; @@ -20,7 +20,14 @@ use WP_REST_Response; */ class PaymentSaleCompleted implements RequestHandler { - use TransactionIdHandlingTrait, RequestHandlerTrait; + use RequestHandlerTrait; + + /** + * Renewal handler. + * + * @var RenewalHandler + */ + private $renewal_handler; /** * The logger. @@ -33,9 +40,11 @@ class PaymentSaleCompleted implements RequestHandler { * PaymentSaleCompleted constructor. * * @param LoggerInterface $logger The logger. + * @param RenewalHandler $renewal_handler Renewal handler. */ - public function __construct( LoggerInterface $logger ) { - $this->logger = $logger; + public function __construct( LoggerInterface $logger, RenewalHandler $renewal_handler ) { + $this->logger = $logger; + $this->renewal_handler = $renewal_handler; } /** @@ -68,7 +77,7 @@ class PaymentSaleCompleted implements RequestHandler { */ public function handle_request( WP_REST_Request $request ): WP_REST_Response { if ( is_null( $request['resource'] ) ) { - return $this->failure_response(); + return $this->failure_response( 'Could not retrieve resource.' ); } if ( ! function_exists( 'wcs_get_subscriptions' ) ) { @@ -85,7 +94,7 @@ class PaymentSaleCompleted implements RequestHandler { return $this->failure_response( 'Could not retrieve transaction id for subscription.' ); } - $args = array( + $args = array( // phpcs:ignore WordPress.DB.SlowDBQuery 'meta_query' => array( array( @@ -95,24 +104,13 @@ class PaymentSaleCompleted implements RequestHandler { ), ), ); - $subscriptions = wcs_get_subscriptions( $args ); - foreach ( $subscriptions as $subscription ) { - $is_renewal = $subscription->get_meta( '_ppcp_is_subscription_renewal' ) ?? ''; - if ( $is_renewal ) { - $renewal_order = wcs_create_renewal_order( $subscription ); - if ( is_a( $renewal_order, WC_Order::class ) ) { - $renewal_order->set_payment_method( $subscription->get_payment_method() ); - $renewal_order->payment_complete(); - $this->update_transaction_id( $transaction_id, $renewal_order, $this->logger ); - break; - } - } - $parent_order = wc_get_order( $subscription->get_parent() ); - if ( is_a( $parent_order, WC_Order::class ) ) { - $subscription->update_meta_data( '_ppcp_is_subscription_renewal', 'true' ); - $subscription->save_meta_data(); - $this->update_transaction_id( $transaction_id, $parent_order, $this->logger ); + $subscriptions = wcs_get_subscriptions( $args ); + if ( $subscriptions ) { + try { + $this->renewal_handler->process( $subscriptions, $transaction_id ); + } catch ( WC_Data_Exception $exception ) { + return $this->failure_response( 'Could not update payment method.' ); } } From 17cff6bd09f182dd4f11e823ad54aab2c913e76f Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Fri, 13 Sep 2024 17:05:39 +0200 Subject: [PATCH 02/28] Add test for renewal handler --- .../PayPalSubscriptionsRenewalTest.php | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php diff --git a/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php new file mode 100644 index 000000000..f67b6be55 --- /dev/null +++ b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php @@ -0,0 +1,42 @@ +getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + $order = wc_create_order(); + $order->set_customer_id( 1 ); + $order->save(); + + $subscription = wcs_create_subscription( + array( + 'order_id' => $order->get_id(), + 'status' => 'active', + 'billing_period' => WC_Subscriptions_Product::get_period( $_ENV['PAYPAL_SUBSCRIPTIONS_PRODUCT_ID'] ), + 'billing_interval' => WC_Subscriptions_Product::get_interval( $_ENV['PAYPAL_SUBSCRIPTIONS_PRODUCT_ID'] ), + 'customer_id' => $order->get_customer_id(), + ) + ); + + $parent = $subscription->get_related_orders( 'ids', array( 'parent' ) ); + $this->assertEquals(count($parent), 1); + $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); + $this->assertEquals(count($renewal), 0); + + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); + $this->assertEquals(count($renewal), 0); + + $handler->process([$subscription], 'TRANSACTION-ID'); + $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); + $this->assertEquals(count($renewal), 1); + } +} From 80e64575025e059392cbe606022dbab7fac9d295 Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Mon, 16 Sep 2024 14:58:25 +0200 Subject: [PATCH 03/28] Extract is renewal logic to method --- .../src/RenewalHandler.php | 18 ++++++++- .../PayPalSubscriptionsRenewalTest.php | 39 +++++++++---------- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php index 1261f418e..898a81c57 100644 --- a/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php +++ b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php @@ -48,8 +48,7 @@ class RenewalHandler { */ public function process( array $subscriptions, string $transaction_id ): void { foreach ( $subscriptions as $subscription ) { - $is_renewal = $subscription->get_meta( '_ppcp_is_subscription_renewal' ) ?? ''; - if ( $is_renewal ) { + if ( $this->is_renewal( $subscription ) ) { $renewal_order = wcs_create_renewal_order( $subscription ); if ( is_a( $renewal_order, WC_Order::class ) ) { $renewal_order->set_payment_method( $subscription->get_payment_method() ); @@ -67,4 +66,19 @@ class RenewalHandler { } } } + + /** + * Checks whether subscription order is for renewal or not. + * + * @param WC_Subscription $subscription WC Subscription. + * @return bool + */ + private function is_renewal( WC_Subscription $subscription ): bool { + $subscription_renewal_meta = $subscription->get_meta( '_ppcp_is_subscription_renewal' ) ?? ''; + if ( $subscription_renewal_meta === 'true' ) { + return true; + } + + return false; + } } diff --git a/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php index f67b6be55..f951aadc2 100644 --- a/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php +++ b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php @@ -2,34 +2,16 @@ namespace WooCommerce\PayPalCommerce\Tests\E2e; -use WC_Subscriptions_Product; use WooCommerce\PayPalCommerce\PayPalSubscriptions\RenewalHandler; class PayPalSubscriptionsRenewalTest extends TestCase { - public function test_process() + public function test_is_renewal_by_meta() { $c = $this->getContainer(); $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); - $order = wc_create_order(); - $order->set_customer_id( 1 ); - $order->save(); - - $subscription = wcs_create_subscription( - array( - 'order_id' => $order->get_id(), - 'status' => 'active', - 'billing_period' => WC_Subscriptions_Product::get_period( $_ENV['PAYPAL_SUBSCRIPTIONS_PRODUCT_ID'] ), - 'billing_interval' => WC_Subscriptions_Product::get_interval( $_ENV['PAYPAL_SUBSCRIPTIONS_PRODUCT_ID'] ), - 'customer_id' => $order->get_customer_id(), - ) - ); - - $parent = $subscription->get_related_orders( 'ids', array( 'parent' ) ); - $this->assertEquals(count($parent), 1); - $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); - $this->assertEquals(count($renewal), 0); + $subscription = $this->createSubscription(); $handler->process([$subscription], 'TRANSACTION-ID'); $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); @@ -39,4 +21,21 @@ class PayPalSubscriptionsRenewalTest extends TestCase $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); $this->assertEquals(count($renewal), 1); } + + private function createSubscription() + { + $order = wc_create_order(); + $order->set_customer_id(1); + $order->save(); + + return wcs_create_subscription( + array( + 'order_id' => $order->get_id(), + 'status' => 'active', + 'billing_period' => 'day', + 'billing_interval' => '1', + 'customer_id' => $order->get_customer_id(), + ) + ); + } } From 3d018945f49d2227d834fa6dfd5b82de5793239f Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Tue, 17 Sep 2024 12:05:35 +0200 Subject: [PATCH 04/28] Create subscription via rest api for testing --- .../PayPalSubscriptionsRenewalTest.php | 73 ++++++++++++++++--- 1 file changed, 64 insertions(+), 9 deletions(-) diff --git a/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php index f951aadc2..2e1229685 100644 --- a/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php +++ b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php @@ -24,18 +24,73 @@ class PayPalSubscriptionsRenewalTest extends TestCase private function createSubscription() { - $order = wc_create_order(); - $order->set_customer_id(1); - $order->save(); + $args = [ + 'method' => 'POST', + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode( 'admin:admin' ), + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode([ + 'customer_id' => 1, + 'set_paid' => true, + 'payment_method' => 'ppcp-gateway', + 'billing' => [ + 'first_name' => 'John', + 'last_name' => 'Doe', + 'address_1' => '969 Market', + 'address_2' => '', + 'city' => 'San Francisco', + 'state' => 'CA', + 'postcode' => '94103', + 'country' => 'US', + 'email' => 'john.doe@example.com', + 'phone' => '(555) 555-5555' + ], + 'line_items' => [ + [ + 'product_id' => 156, + 'quantity' => 1 + ] + ], + ]), + ]; - return wcs_create_subscription( - array( - 'order_id' => $order->get_id(), + $response = wp_remote_request( + 'https://woocommerce-paypal-payments.ddev.site/wp-json/wc/v3/orders', + $args + ); + + $body = json_decode( $response['body'] ); + + $args = [ + 'method' => 'POST', + 'headers' => [ + 'Authorization' => 'Basic ' . base64_encode( 'admin:admin' ), + 'Content-Type' => 'application/json', + ], + 'body' => wp_json_encode([ + 'parent_id' => $body->id, + 'customer_id' => 1, 'status' => 'active', 'billing_period' => 'day', - 'billing_interval' => '1', - 'customer_id' => $order->get_customer_id(), - ) + 'billing_interval' => 1, + 'payment_method' => 'ppcp-gateway', + 'line_items' => [ + [ + 'product_id' => $_ENV['PAYPAL_SUBSCRIPTIONS_PRODUCT_ID'], + 'quantity' => 1 + ] + ], + ]), + ]; + + $response = wp_remote_request( + 'https://woocommerce-paypal-payments.ddev.site/wp-json/wc/v3/subscriptions?per_page=1', + $args ); + + $body = json_decode( $response['body'] ); + + return wcs_get_subscription($body->id); } } From eb19f05226184e737489d087faf094b631e9b54d Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Wed, 18 Sep 2024 11:52:28 +0200 Subject: [PATCH 05/28] Do not return if `_ppcp_enable_subscription_product` not exist in the post request --- .../src/PayPalSubscriptionsModule.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php index 07dd5c8c6..1319feae9 100644 --- a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php +++ b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php @@ -66,9 +66,7 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu function( $product_id ) use ( $c ) { $subscriptions_helper = $c->get( 'wc-subscriptions.helper' ); assert( $subscriptions_helper instanceof SubscriptionHelper ); - - $connect_subscription = wc_clean( wp_unslash( $_POST['_ppcp_enable_subscription_product'] ?? '' ) ); - if ( ! $subscriptions_helper->plugin_is_active() || $connect_subscription !== 'yes' ) { + if ( ! $subscriptions_helper->plugin_is_active() ) { return; } From 750e5891c41480b1b380c55c11d9ed8c48f854a3 Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Wed, 18 Sep 2024 12:15:32 +0200 Subject: [PATCH 06/28] Revert latest change, will be included in its own PR --- .../src/PayPalSubscriptionsModule.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php index 1319feae9..07dd5c8c6 100644 --- a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php +++ b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php @@ -66,7 +66,9 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu function( $product_id ) use ( $c ) { $subscriptions_helper = $c->get( 'wc-subscriptions.helper' ); assert( $subscriptions_helper instanceof SubscriptionHelper ); - if ( ! $subscriptions_helper->plugin_is_active() ) { + + $connect_subscription = wc_clean( wp_unslash( $_POST['_ppcp_enable_subscription_product'] ?? '' ) ); + if ( ! $subscriptions_helper->plugin_is_active() || $connect_subscription !== 'yes' ) { return; } From 2f21df65b38c27af0e8d73bb7e85553ce7436211 Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Wed, 18 Sep 2024 14:43:41 +0200 Subject: [PATCH 07/28] Add time based logic for checking if is order is parent or for renewal --- .../src/RenewalHandler.php | 29 +++++++++++++++++-- .../PayPalSubscriptionsRenewalTest.php | 17 +++++++++-- 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php index 898a81c57..f88fea7d8 100644 --- a/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php +++ b/modules/ppcp-paypal-subscriptions/src/RenewalHandler.php @@ -48,9 +48,17 @@ class RenewalHandler { */ public function process( array $subscriptions, string $transaction_id ): void { foreach ( $subscriptions as $subscription ) { - if ( $this->is_renewal( $subscription ) ) { + if ( $this->is_for_renewal_order( $subscription ) ) { $renewal_order = wcs_create_renewal_order( $subscription ); if ( is_a( $renewal_order, WC_Order::class ) ) { + $this->logger->info( + sprintf( + 'Processing renewal order #%s for subscription #%s', + $renewal_order->get_id(), + $subscription->get_id() + ) + ); + $renewal_order->set_payment_method( $subscription->get_payment_method() ); $renewal_order->payment_complete(); $this->update_transaction_id( $transaction_id, $renewal_order, $this->logger ); @@ -60,6 +68,14 @@ class RenewalHandler { $parent_order = wc_get_order( $subscription->get_parent() ); if ( is_a( $parent_order, WC_Order::class ) ) { + $this->logger->info( + sprintf( + 'Processing parent order #%s for subscription #%s', + $parent_order->get_id(), + $subscription->get_id() + ) + ); + $subscription->update_meta_data( '_ppcp_is_subscription_renewal', 'true' ); $subscription->save_meta_data(); $this->update_transaction_id( $transaction_id, $parent_order, $this->logger ); @@ -73,12 +89,19 @@ class RenewalHandler { * @param WC_Subscription $subscription WC Subscription. * @return bool */ - private function is_renewal( WC_Subscription $subscription ): bool { + private function is_for_renewal_order( WC_Subscription $subscription ): bool { $subscription_renewal_meta = $subscription->get_meta( '_ppcp_is_subscription_renewal' ) ?? ''; if ( $subscription_renewal_meta === 'true' ) { return true; } - return false; + if ( + time() >= $subscription->get_time( 'start' ) + && ( time() - $subscription->get_time( 'start' ) ) <= ( 8 * HOUR_IN_SECONDS ) + ) { + return false; + } + + return true; } } diff --git a/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php index 2e1229685..322b54731 100644 --- a/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php +++ b/tests/e2e/PHPUnit/PayPalSubscriptionsRenewalTest.php @@ -6,23 +6,33 @@ use WooCommerce\PayPalCommerce\PayPalSubscriptions\RenewalHandler; class PayPalSubscriptionsRenewalTest extends TestCase { - public function test_is_renewal_by_meta() + public function test_parent_order() { $c = $this->getContainer(); $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); - $subscription = $this->createSubscription(); + // Simulates receiving webhook 1 minute after subscription start. + $subscription = $this->createSubscription('-1 minute'); $handler->process([$subscription], 'TRANSACTION-ID'); $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); $this->assertEquals(count($renewal), 0); + } + + public function test_renewal_order() + { + $c = $this->getContainer(); + $handler = new RenewalHandler($c->get('woocommerce.logger.woocommerce')); + + // Simulates receiving webhook 9 hours after subscription start. + $subscription = $this->createSubscription('-9 hour'); $handler->process([$subscription], 'TRANSACTION-ID'); $renewal = $subscription->get_related_orders( 'ids', array( 'renewal' ) ); $this->assertEquals(count($renewal), 1); } - private function createSubscription() + private function createSubscription(string $startDate) { $args = [ 'method' => 'POST', @@ -69,6 +79,7 @@ class PayPalSubscriptionsRenewalTest extends TestCase 'Content-Type' => 'application/json', ], 'body' => wp_json_encode([ + 'start_date' => gmdate( 'Y-m-d H:i:s', strtotime($startDate) ), 'parent_id' => $body->id, 'customer_id' => 1, 'status' => 'active', From a2d02cd7f4399f15da4f684757fd22339425e92e Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Fri, 20 Sep 2024 12:15:11 +0200 Subject: [PATCH 08/28] Extrat update subscription status logic to its own class --- .../ppcp-paypal-subscriptions/services.php | 6 ++ .../src/PayPalSubscriptionsModule.php | 57 ++--------- .../src/SubscriptionStatus.php | 96 +++++++++++++++++++ 3 files changed, 111 insertions(+), 48 deletions(-) create mode 100644 modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php diff --git a/modules/ppcp-paypal-subscriptions/services.php b/modules/ppcp-paypal-subscriptions/services.php index 4e64e678b..a39a84c8a 100644 --- a/modules/ppcp-paypal-subscriptions/services.php +++ b/modules/ppcp-paypal-subscriptions/services.php @@ -40,4 +40,10 @@ return array( dirname( realpath( __FILE__ ), 3 ) . '/woocommerce-paypal-payments.php' ); }, + 'paypal-subscriptions.status' => static function ( ContainerInterface $container ): SubscriptionStatus { + return new SubscriptionStatus( + $container->get( 'api.endpoint.billing-subscriptions' ), + $container->get( 'woocommerce.logger.woocommerce' ) + ); + }, ); diff --git a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php index 07dd5c8c6..ba88097be 100644 --- a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php +++ b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php @@ -189,6 +189,9 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu 30 ); + /** + * Executed when updating WC Subscription. + */ add_action( 'woocommerce_process_shop_subscription_meta', /** @@ -196,65 +199,23 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu * * @psalm-suppress MissingClosureParamType */ - function( $id, $post ) use ( $c ) { + function( $id ) use ( $c ) { $subscription = wcs_get_subscription( $id ); if ( ! is_a( $subscription, WC_Subscription::class ) ) { return; } + $subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? ''; if ( ! $subscription_id ) { return; } - $subscriptions_endpoint = $c->get( 'api.endpoint.billing-subscriptions' ); - assert( $subscriptions_endpoint instanceof BillingSubscriptions ); - if ( $subscription->get_status() === 'cancelled' ) { - try { - $subscriptions_endpoint->cancel( $subscription_id ); - } catch ( RuntimeException $exception ) { - $error = $exception->getMessage(); - if ( is_a( $exception, PayPalApiException::class ) ) { - $error = $exception->get_details( $error ); - } + $subscription_status = $c->get( 'paypal-subscriptions.status' ); + assert( $subscription_status instanceof SubscriptionStatus ); - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - $logger->error( 'Could not cancel subscription product on PayPal. ' . $error ); - } - } - - if ( $subscription->get_status() === 'pending-cancel' ) { - try { - $subscriptions_endpoint->suspend( $subscription_id ); - } catch ( RuntimeException $exception ) { - $error = $exception->getMessage(); - if ( is_a( $exception, PayPalApiException::class ) ) { - $error = $exception->get_details( $error ); - } - - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - $logger->error( 'Could not suspend subscription product on PayPal. ' . $error ); - } - } - - if ( $subscription->get_status() === 'active' ) { - try { - $current_subscription = $subscriptions_endpoint->subscription( $subscription_id ); - if ( $current_subscription->status === 'SUSPENDED' ) { - $subscriptions_endpoint->activate( $subscription_id ); - } - } catch ( RuntimeException $exception ) { - $error = $exception->getMessage(); - if ( is_a( $exception, PayPalApiException::class ) ) { - $error = $exception->get_details( $error ); - } - - $logger = $c->get( 'woocommerce.logger.woocommerce' ); - $logger->error( 'Could not reactivate subscription product on PayPal. ' . $error ); - } - } + $subscription_status->update_status( $subscription->get_status(), $subscription_id ); }, - 20, - 2 + 20 ); add_filter( diff --git a/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php b/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php new file mode 100644 index 000000000..f5a0217ac --- /dev/null +++ b/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php @@ -0,0 +1,96 @@ +subscriptions_endpoint = $subscriptions_endpoint; + $this->logger = $logger; + } + + /** + * Updates PayPal subscription status from the given WC Subscription status. + * + * @param string $subscription_status The WC Subscription status. + * @param string $subscription_id The PayPal Subscription ID. + * @return void + */ + public function update_status(string $subscription_status, string $subscription_id): void { + if ($subscription_status === 'cancelled') { + try { + $this->subscriptions_endpoint->cancel($subscription_id); + } catch (RuntimeException $exception) { + + $this->logger->error('Could not cancel subscription product on PayPal. ' + . $this->get_error($exception)); + } + } + + if ($subscription_status === 'pending-cancel') { + try { + $this->subscriptions_endpoint->suspend($subscription_id); + } catch (RuntimeException $exception) { + $this->logger->error('Could not suspend subscription product on PayPal. ' + . $this->get_error($exception)); + } + } + + if ($subscription_status === 'active') { + try { + $current_subscription = $this->subscriptions_endpoint->subscription($subscription_id); + if ($current_subscription->status === 'SUSPENDED') { + $this->subscriptions_endpoint->activate($subscription_id); + } + } catch (RuntimeException $exception) { + $this->logger->error('Could not reactivate subscription product on PayPal. ' + . $this->get_error($exception)); + } + } + } + + /** + * Get error from exception. + * + * @param RuntimeException $exception The exception. + * @return string + */ + private function get_error(RuntimeException $exception): string { + $error = $exception->getMessage(); + if (is_a($exception, PayPalApiException::class)) { + $error = $exception->get_details($error); + } + + return $error; + } +} From 6518ae06552070658f4f5e0ec995dfd0c6e58998 Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Fri, 20 Sep 2024 12:49:11 +0200 Subject: [PATCH 09/28] Updates subscription status from subscriptions list page --- .../src/PayPalSubscriptionsModule.php | 44 +++++++-- .../src/SubscriptionStatus.php | 91 ++++++++++++++----- 2 files changed, 105 insertions(+), 30 deletions(-) diff --git a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php index ba88097be..681645444 100644 --- a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php +++ b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php @@ -200,13 +200,9 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu * @psalm-suppress MissingClosureParamType */ function( $id ) use ( $c ) { - $subscription = wcs_get_subscription( $id ); - if ( ! is_a( $subscription, WC_Subscription::class ) ) { - return; - } - + $subscription = wcs_get_subscription( $id ); $subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? ''; - if ( ! $subscription_id ) { + if ( ! is_a( $subscription, WC_Subscription::class ) || ! $subscription_id ) { return; } @@ -218,6 +214,42 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu 20 ); + /** + * Update status to pending-cancel from WC Subscriptions list page action link. + */ + add_action( + 'woocommerce_subscription_status_pending-cancel', + function( WC_Subscription $subscription ) use ( $c ) { + $subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? ''; + if ( ! $subscription_id ) { + return; + } + + $subscription_status = $c->get( 'paypal-subscriptions.status' ); + assert( $subscription_status instanceof SubscriptionStatus ); + + $subscription_status->update_status( $subscription->get_status(), $subscription_id ); + } + ); + + /** + * Update status to cancelled from WC Subscriptions list page action link. + */ + add_action( + 'woocommerce_subscription_status_cancelled', + function( WC_Subscription $subscription ) use ( $c ) { + $subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? ''; + if ( ! $subscription_id ) { + return; + } + + $subscription_status = $c->get( 'paypal-subscriptions.status' ); + assert( $subscription_status instanceof SubscriptionStatus ); + + $subscription_status->update_status( $subscription->get_status(), $subscription_id ); + } + ); + add_filter( 'woocommerce_order_actions', /** diff --git a/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php b/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php index f5a0217ac..1b969ee49 100644 --- a/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php +++ b/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php @@ -13,8 +13,10 @@ use Psr\Log\LoggerInterface; use WooCommerce\PayPalCommerce\ApiClient\Endpoint\BillingSubscriptions; use WooCommerce\PayPalCommerce\ApiClient\Exception\PayPalApiException; use WooCommerce\PayPalCommerce\ApiClient\Exception\RuntimeException; -use WooCommerce\PayPalCommerce\Vendor\Psr\Container\ContainerInterface; +/** + * Class SubscriptionStatus + */ class SubscriptionStatus { /** @@ -22,7 +24,7 @@ class SubscriptionStatus { * * @var BillingSubscriptions */ - private $subscriptions_endpoint ; + private $subscriptions_endpoint; /** * The logger. @@ -31,12 +33,18 @@ class SubscriptionStatus { */ private $logger; + /** + * SubscriptionStatus constructor. + * + * @param BillingSubscriptions $subscriptions_endpoint Billing subscriptions endpoint. + * @param LoggerInterface $logger The logger. + */ public function __construct( BillingSubscriptions $subscriptions_endpoint, LoggerInterface $logger ) { $this->subscriptions_endpoint = $subscriptions_endpoint; - $this->logger = $logger; + $this->logger = $logger; } /** @@ -46,35 +54,70 @@ class SubscriptionStatus { * @param string $subscription_id The PayPal Subscription ID. * @return void */ - public function update_status(string $subscription_status, string $subscription_id): void { - if ($subscription_status === 'cancelled') { + public function update_status( string $subscription_status, string $subscription_id ): void { + if ( $subscription_status === 'cancelled' ) { try { - $this->subscriptions_endpoint->cancel($subscription_id); - } catch (RuntimeException $exception) { + $this->logger->info( + sprintf( + 'Canceling PayPal subscription #%s.', + $subscription_id + ) + ); - $this->logger->error('Could not cancel subscription product on PayPal. ' - . $this->get_error($exception)); + $this->subscriptions_endpoint->cancel( $subscription_id ); + } catch ( RuntimeException $exception ) { + $this->logger->error( + sprintf( + 'Could not cancel PayPal subscription #%s. %s', + $subscription_id, + $this->get_error( $exception ) + ) + ); } } - if ($subscription_status === 'pending-cancel') { + if ( $subscription_status === 'pending-cancel' || $subscription_status === 'on-hold' ) { try { - $this->subscriptions_endpoint->suspend($subscription_id); - } catch (RuntimeException $exception) { - $this->logger->error('Could not suspend subscription product on PayPal. ' - . $this->get_error($exception)); + $this->logger->info( + sprintf( + 'Suspending PayPal subscription #%s.', + $subscription_id + ) + ); + + $this->subscriptions_endpoint->suspend( $subscription_id ); + } catch ( RuntimeException $exception ) { + $this->logger->error( + sprintf( + 'Could not suspend PayPal subscription #%s. %s', + $subscription_id, + $this->get_error( $exception ) + ) + ); } } - if ($subscription_status === 'active') { + if ( $subscription_status === 'active' ) { try { - $current_subscription = $this->subscriptions_endpoint->subscription($subscription_id); - if ($current_subscription->status === 'SUSPENDED') { - $this->subscriptions_endpoint->activate($subscription_id); + $current_subscription = $this->subscriptions_endpoint->subscription( $subscription_id ); + if ( $current_subscription->status === 'SUSPENDED' ) { + $this->logger->info( + sprintf( + 'Activating suspended PayPal subscription #%s.', + $subscription_id + ) + ); + + $this->subscriptions_endpoint->activate( $subscription_id ); } - } catch (RuntimeException $exception) { - $this->logger->error('Could not reactivate subscription product on PayPal. ' - . $this->get_error($exception)); + } catch ( RuntimeException $exception ) { + $this->logger->error( + sprintf( + 'Could not reactivate PayPal subscription #%s. %s', + $subscription_id, + $this->get_error( $exception ) + ) + ); } } } @@ -85,10 +128,10 @@ class SubscriptionStatus { * @param RuntimeException $exception The exception. * @return string */ - private function get_error(RuntimeException $exception): string { + private function get_error( RuntimeException $exception ): string { $error = $exception->getMessage(); - if (is_a($exception, PayPalApiException::class)) { - $error = $exception->get_details($error); + if ( is_a( $exception, PayPalApiException::class ) ) { + $error = $exception->get_details( $error ); } return $error; From c056b33b3c9fb561b825785d0e7c79d71c1cab6f Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Fri, 20 Sep 2024 15:25:49 +0200 Subject: [PATCH 10/28] Use `woocommerce_subscription_status_updated` for all subscription status updates --- .../src/Endpoint/BillingSubscriptions.php | 2 +- .../src/PayPalSubscriptionsModule.php | 22 ++----------------- .../src/SubscriptionStatus.php | 9 ++++++-- 3 files changed, 10 insertions(+), 23 deletions(-) diff --git a/modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php b/modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php index 8a46506b6..91380ac53 100644 --- a/modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php +++ b/modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php @@ -148,7 +148,7 @@ class BillingSubscriptions { */ public function cancel( string $id ): void { $data = array( - 'reason' => 'Cancelled by customer', + 'reason' => sprintf( 'Cancelled by %s.', is_admin() ? 'merchant' : 'customer' ), ); $bearer = $this->bearer->bearer(); diff --git a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php index 681645444..b55addb69 100644 --- a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php +++ b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php @@ -215,28 +215,10 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu ); /** - * Update status to pending-cancel from WC Subscriptions list page action link. + * Update subscription status from WC Subscriptions list page action link. */ add_action( - 'woocommerce_subscription_status_pending-cancel', - function( WC_Subscription $subscription ) use ( $c ) { - $subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? ''; - if ( ! $subscription_id ) { - return; - } - - $subscription_status = $c->get( 'paypal-subscriptions.status' ); - assert( $subscription_status instanceof SubscriptionStatus ); - - $subscription_status->update_status( $subscription->get_status(), $subscription_id ); - } - ); - - /** - * Update status to cancelled from WC Subscriptions list page action link. - */ - add_action( - 'woocommerce_subscription_status_cancelled', + 'woocommerce_subscription_status_updated', function( WC_Subscription $subscription ) use ( $c ) { $subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? ''; if ( ! $subscription_id ) { diff --git a/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php b/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php index 1b969ee49..1fbec3926 100644 --- a/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php +++ b/modules/ppcp-paypal-subscriptions/src/SubscriptionStatus.php @@ -55,8 +55,13 @@ class SubscriptionStatus { * @return void */ public function update_status( string $subscription_status, string $subscription_id ): void { - if ( $subscription_status === 'cancelled' ) { + if ( $subscription_status === 'pending-cancel' || $subscription_status === 'cancelled' ) { try { + $current_subscription = $this->subscriptions_endpoint->subscription( $subscription_id ); + if ( $current_subscription->status === 'CANCELLED' ) { + return; + } + $this->logger->info( sprintf( 'Canceling PayPal subscription #%s.', @@ -76,7 +81,7 @@ class SubscriptionStatus { } } - if ( $subscription_status === 'pending-cancel' || $subscription_status === 'on-hold' ) { + if ( $subscription_status === 'on-hold' ) { try { $this->logger->info( sprintf( From aafa11ad2be7630586bf47a40ec4f496bc1f77f4 Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Mon, 23 Sep 2024 09:38:23 +0200 Subject: [PATCH 11/28] Fix psalm --- .../src/PayPalSubscriptionsModule.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php index b55addb69..009c036c5 100644 --- a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php +++ b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php @@ -200,7 +200,11 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu * @psalm-suppress MissingClosureParamType */ function( $id ) use ( $c ) { - $subscription = wcs_get_subscription( $id ); + $subscription = wcs_get_subscription( $id ); + if ( $subscription === false ) { + return; + } + $subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? ''; if ( ! is_a( $subscription, WC_Subscription::class ) || ! $subscription_id ) { return; From 150843298108ccff0483a342c785c0f2a2dc651b Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Mon, 23 Sep 2024 09:40:20 +0200 Subject: [PATCH 12/28] Fix psalm --- .../ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php index 009c036c5..69dd3d841 100644 --- a/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php +++ b/modules/ppcp-paypal-subscriptions/src/PayPalSubscriptionsModule.php @@ -206,7 +206,7 @@ class PayPalSubscriptionsModule implements ServiceModule, ExtendingModule, Execu } $subscription_id = $subscription->get_meta( 'ppcp_subscription' ) ?? ''; - if ( ! is_a( $subscription, WC_Subscription::class ) || ! $subscription_id ) { + if ( ! $subscription_id ) { return; } From 4c0a0c1c6d58a0efd849703600bb2b69d1782599 Mon Sep 17 00:00:00 2001 From: Emili Castells Guasch Date: Wed, 2 Oct 2024 11:40:32 +0200 Subject: [PATCH 13/28] Update reason message based on context --- modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php b/modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php index 91380ac53..281afe0ec 100644 --- a/modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php +++ b/modules/ppcp-api-client/src/Endpoint/BillingSubscriptions.php @@ -67,7 +67,7 @@ class BillingSubscriptions { */ public function suspend( string $id ):void { $data = array( - 'reason' => 'Suspended by customer', + 'reason' => sprintf( 'Suspended by %s.', is_admin() ? 'merchant' : 'customer' ), ); $bearer = $this->bearer->bearer(); @@ -107,7 +107,7 @@ class BillingSubscriptions { */ public function activate( string $id ): void { $data = array( - 'reason' => 'Reactivated by customer', + 'reason' => sprintf( 'Reactivated by %s.', is_admin() ? 'merchant' : 'customer' ), ); $bearer = $this->bearer->bearer(); From 22945c4df51bb0f5fbee6e37211451d22d4c1237 Mon Sep 17 00:00:00 2001 From: Alex P Date: Wed, 9 Oct 2024 10:30:36 +0300 Subject: [PATCH 14/28] 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'), }, From 0611098b9280c5841fd3ba1f948d2cea75c3cf24 Mon Sep 17 00:00:00 2001 From: inpsyde-maticluznar Date: Wed, 9 Oct 2024 09:36:29 +0200 Subject: [PATCH 15/28] Place all task registration logic inside the init action --- .../ppcp-wc-gateway/src/WCGatewayModule.php | 22 +++++++++---------- 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/modules/ppcp-wc-gateway/src/WCGatewayModule.php b/modules/ppcp-wc-gateway/src/WCGatewayModule.php index 2471af4e7..583b02914 100644 --- a/modules/ppcp-wc-gateway/src/WCGatewayModule.php +++ b/modules/ppcp-wc-gateway/src/WCGatewayModule.php @@ -848,21 +848,19 @@ class WCGatewayModule implements ServiceModule, ExtendingModule, ExecutableModul * @return void */ protected function register_wc_tasks( ContainerInterface $container ): void { - $simple_redirect_tasks = $container->get( 'wcgateway.settings.wc-tasks.simple-redirect-tasks' ); - if ( empty( $simple_redirect_tasks ) ) { - return; - } - - $task_registrar = $container->get( 'wcgateway.settings.wc-tasks.task-registrar' ); - assert( $task_registrar instanceof TaskRegistrarInterface ); - - $logger = $container->get( 'woocommerce.logger.woocommerce' ); - assert( $logger instanceof LoggerInterface ); - add_action( 'init', - static function () use ( $simple_redirect_tasks, $task_registrar, $logger ): void { + static function () use ( $container ): void { + $logger = $container->get( 'woocommerce.logger.woocommerce' ); + assert( $logger instanceof LoggerInterface ); try { + $simple_redirect_tasks = $container->get( 'wcgateway.settings.wc-tasks.simple-redirect-tasks' ); + if ( empty( $simple_redirect_tasks ) ) { + return; + } + $task_registrar = $container->get( 'wcgateway.settings.wc-tasks.task-registrar' ); + assert( $task_registrar instanceof TaskRegistrarInterface ); + $task_registrar->register( $simple_redirect_tasks ); } catch ( Exception $exception ) { $logger->error( "Failed to create a task in the 'Things to do next' section of WC. " . $exception->getMessage() ); From 7c111906ab400affa81ee1fa767002f5cbaa6df2 Mon Sep 17 00:00:00 2001 From: inpsyde-maticluznar Date: Thu, 10 Oct 2024 07:22:22 +0200 Subject: [PATCH 16/28] Position the card icons beside the label instead of beneath the fields on the checkout form. --- .../resources/js/Components/card-fields.js | 6 --- .../js/advanced-card-checkout-block.js | 45 ++++++++++++------- 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/modules/ppcp-blocks/resources/js/Components/card-fields.js b/modules/ppcp-blocks/resources/js/Components/card-fields.js index 2726bc12e..05d41b315 100644 --- a/modules/ppcp-blocks/resources/js/Components/card-fields.js +++ b/modules/ppcp-blocks/resources/js/Components/card-fields.js @@ -19,11 +19,9 @@ export function CardFields( { config, eventRegistration, emitResponse, - components, } ) { const { onPaymentSetup } = eventRegistration; const { responseTypes } = emitResponse; - const { PaymentMethodIcons } = components; const [ cardFieldsForm, setCardFieldsForm ] = useState(); const getCardFieldsForm = ( cardFieldsForm ) => { @@ -95,10 +93,6 @@ export function CardFields( { } } > - , - content: , - edit: , - ariaLabel: config.title, - canMakePayment: () => { - return true; - }, - supports: { - showSavedCards: true, - features: config.supports, - }, -} ); +const Label = ({components, config}) => { + const {PaymentMethodIcons} = components; + return <> + + + +} + +registerPaymentMethod({ + name: config.id, + label: