From 98eaa7a59022a972cb8f848fd6c6e9180f158ab6 Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Mon, 29 Sep 2025 17:27:04 -0400 Subject: [PATCH] fix: Checkout notices further implemented --- includes/class-type-registry.php | 2 + includes/class-wp-graphql-woocommerce.php | 2 + .../data/mutation/class-checkout-mutation.php | 44 ++- includes/mutation/class-checkout.php | 90 +++++- includes/type/enum/class-cart-notice-type.php | 42 +++ includes/type/object/class-cart-notice.php | 44 +++ tests/wpunit/CartMutationsTest.php | 2 +- tests/wpunit/CheckoutMutationTest.php | 63 ++--- tests/wpunit/CheckoutNoticesTest.php | 258 ++++++++++++++++++ 9 files changed, 492 insertions(+), 55 deletions(-) create mode 100644 includes/type/enum/class-cart-notice-type.php create mode 100644 includes/type/object/class-cart-notice.php create mode 100644 tests/wpunit/CheckoutNoticesTest.php diff --git a/includes/class-type-registry.php b/includes/class-type-registry.php index 5b04240f..de010259 100644 --- a/includes/class-type-registry.php +++ b/includes/class-type-registry.php @@ -48,6 +48,7 @@ public function init() { Type\WPEnum\Currency_Enum::register(); Type\WPEnum\Shipping_Location_Type_Enum::register(); Type\WPEnum\WC_Setting_Type_Enum::register(); + Type\WPEnum\Cart_Notice_Type::register(); Type\WPEnum\Product_Attributes_Connection_Orderby_Enum::register(); /** @@ -121,6 +122,7 @@ public function init() { Type\WPObject\Shipping_Location_Type::register(); Type\WPObject\Tax_Class_Type::register(); Type\WPObject\WC_Setting_Type::register(); + Type\WPObject\Cart_Notice::register(); /** * Object fields. diff --git a/includes/class-wp-graphql-woocommerce.php b/includes/class-wp-graphql-woocommerce.php index 7156baeb..272a34da 100644 --- a/includes/class-wp-graphql-woocommerce.php +++ b/includes/class-wp-graphql-woocommerce.php @@ -246,6 +246,7 @@ private function includes() { require $include_directory_path . 'type/enum/class-currency-enum.php'; require $include_directory_path . 'type/enum/class-shipping-location-type-enum.php'; require $include_directory_path . 'type/enum/class-wc-setting-type-enum.php'; + require $include_directory_path . 'type/enum/class-cart-notice-type.php'; require $include_directory_path . 'type/enum/class-product-attributes-connection-orderby-enum.php'; // Include interface type class files. @@ -266,6 +267,7 @@ private function includes() { // Include object type class files. require $include_directory_path . 'type/object/class-cart-error-types.php'; + require $include_directory_path . 'type/object/class-cart-notice.php'; require $include_directory_path . 'type/object/class-cart-type.php'; require $include_directory_path . 'type/object/class-coupon-type.php'; require $include_directory_path . 'type/object/class-customer-address-type.php'; diff --git a/includes/data/mutation/class-checkout-mutation.php b/includes/data/mutation/class-checkout-mutation.php index fd339dbe..b1bef54b 100644 --- a/includes/data/mutation/class-checkout-mutation.php +++ b/includes/data/mutation/class-checkout-mutation.php @@ -448,31 +448,37 @@ protected static function validate_data( &$data ) { * Validates that the checkout has enough info to proceed. * * @param array $data An array of posted data. + * @param WP_Error $errors Validation errors. * * @throws \GraphQL\Error\UserError Invalid input. * * @return void */ - protected static function validate_checkout( &$data ) { + protected static function validate_checkout( &$data, &$errors ) { self::validate_data( $data ); WC()->checkout()->check_cart_items(); // Throw cart validation errors stored in the session. - $cart_item_errors = wc_get_notices( 'error' ); + // $cart_item_errors = wc_get_notices( 'error' ); - if ( ! empty( $cart_item_errors ) ) { - $cart_item_error_msgs = implode( ' ', array_column( $cart_item_errors, 'notice' ) ); - \wc_clear_notices(); - throw new UserError( $cart_item_error_msgs ); + // if ( ! empty( $cart_item_errors ) ) { + // $cart_item_error_msgs = implode( ' ', array_column( $cart_item_errors, 'notice' ) ); + // \wc_clear_notices(); + // throw new UserError( $cart_item_error_msgs ); + // } + + if ( empty( $data['woocommerce_checkout_update_totals'] ) && empty( $data['terms'] ) && ! empty( $data['terms-field'] ) ) { + $errors->add( 'terms', __( 'Please read and accept the terms and conditions to proceed with your order.', 'woocommerce' ) ); } if ( WC()->cart->needs_shipping() ) { $shipping_country = WC()->customer->get_shipping_country(); if ( empty( $shipping_country ) ) { - throw new UserError( __( 'Please enter an address to continue.', 'wp-graphql-woocommerce' ) ); + $errors->add( 'shipping', __( 'Please enter an address to continue.', 'wp-graphql-woocommerce' ) ); } elseif ( ! in_array( WC()->customer->get_shipping_country(), array_keys( WC()->countries->get_shipping_countries() ), true ) ) { - throw new UserError( + $errors->add( + 'shipping', sprintf( /* translators: %s: shipping location */ __( 'Unfortunately, we do not ship %s. Please enter an alternative shipping address.', 'wp-graphql-woocommerce' ), @@ -484,7 +490,7 @@ protected static function validate_checkout( &$data ) { foreach ( WC()->shipping()->get_packages() as $i => $package ) { if ( ! isset( $chosen_shipping_methods[ $i ], $package['rates'][ $chosen_shipping_methods[ $i ] ] ) ) { - throw new UserError( __( 'No shipping method has been selected. Please double check your address, or contact us if you need any help.', 'wp-graphql-woocommerce' ) ); + $errors->add( 'shipping', __( 'No shipping method has been selected. Please double check your address, or contact us if you need any help.', 'wp-graphql-woocommerce' ) ); } } } @@ -493,14 +499,15 @@ protected static function validate_checkout( &$data ) { if ( WC()->cart->needs_payment() ) { $available_gateways = WC()->payment_gateways->get_available_payment_gateways(); if ( ! isset( $available_gateways[ $data['payment_method'] ] ) ) { - throw new UserError( __( 'Invalid payment method.', 'wp-graphql-woocommerce' ) ); + $errors->add( 'payment', __( 'Invalid payment method.', 'wp-graphql-woocommerce' ) ); } else { $available_gateways[ $data['payment_method'] ]->validate_fields(); } } // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound - do_action( 'woocommerce_after_checkout_validation', $data, new WP_Error() ); + do_action( 'woocommerce_after_checkout_validation', $data, $errors ); + do_action( 'graphql_woocommerce_after_checkout_validation', $data, $errors ); } /** @@ -584,6 +591,7 @@ public static function process_checkout( $data, $input, $context, $info, &$resul do_action( 'woocommerce_checkout_process', $data, $context, $info ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound + if ( ! empty( $input['billing']['overwrite'] ) && true === $input['billing']['overwrite'] ) { self::clear_customer_address( 'billing' ); } @@ -597,7 +605,19 @@ public static function process_checkout( $data, $input, $context, $info, &$resul self::update_session( $data ); // Validate posted data and cart items before proceeding. - self::validate_checkout( $data ); + $errors = new WP_Error(); + self::validate_checkout( $data, $errors ); + + foreach ( $errors->errors as $code => $messages ) { + $data = $errors->get_error_data( $code ); + foreach ( $messages as $message ) { + wc_add_notice( $message, 'error', $data ); + } + } + + if ( 0 < wc_notice_count( 'error' ) ) { + throw new UserError( __('Failed to validate checkout', 'wp-graphql-woocommerce') ); + } self::process_customer( $data ); $order_id = WC()->checkout->create_order( $data ); diff --git a/includes/mutation/class-checkout.php b/includes/mutation/class-checkout.php index f8e9d0d3..f3f80fb1 100644 --- a/includes/mutation/class-checkout.php +++ b/includes/mutation/class-checkout.php @@ -119,6 +119,13 @@ public static function get_output_fields() { return $payload['redirect']; }, ], + 'notices' => [ + 'type' => [ 'list_of' => 'CartNotice' ], + 'description' => __( 'WooCommerce notices generated during checkout', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $payload ) { + return $payload['notices'] ?? []; + }, + ], ]; } @@ -163,17 +170,94 @@ public static function mutate_and_get_payload() { * @param \WPGraphQL\AppContext $context Request AppContext instance. * @param \GraphQL\Type\Definition\ResolveInfo $info Request ResolveInfo instance. */ + // Capture any non-error notices for successful checkouts + $notices = wc_get_notices(); + $formatted_notices = self::format_notices_for_response( $notices ); + + // Clear notices to prevent persistence + wc_clear_notices(); + do_action( 'graphql_woocommerce_after_checkout', $order, $input, $context, $info ); - return array_merge( [ 'id' => $order_id ], $results ); + return array_merge( [ 'id' => $order_id ], $results, [ 'notices' => $formatted_notices ] ); } catch ( \Throwable $e ) { // Delete order if it was created. if ( is_object( $order ) ) { Order_Mutation::purge( $order ); } - // Throw error. - throw new UserError( $e->getMessage() ); + + // Capture any WC notices that were added during checkout process + $notices = wc_get_notices(); + $error_message = $e->getMessage(); + + // If there are notices, use them instead of the original error + if ( ! empty( $notices ) ) { + $formatted_notices = self::format_notices_for_error( $notices ); + if ( ! empty( $formatted_notices ) ) { + $error_message = $formatted_notices; + } + } + + // Clear notices to prevent them from persisting to next request + wc_clear_notices(); + + // Throw error with enhanced message + throw new UserError( $error_message ); }//end try }; } + + /** + * Format WC notices for GraphQL response + * + * @param array $notices WC notices array + * @return array Formatted notices for GraphQL + */ + private static function format_notices_for_response( $notices ) { + $formatted_notices = []; + + // Include non-error notices (success, notice) + foreach ( [ 'success', 'notice' ] as $type ) { + if ( ! empty( $notices[ $type ] ) ) { + foreach ( $notices[ $type ] as $notice ) { + $formatted_notices[] = [ + 'type' => $type, + 'message' => $notice['notice'] ?? $notice, + ]; + } + } + } + + return $formatted_notices; + } + + /** + * Format WC notices for error reporting + * + * @param array $notices WC notices array + * @return string Formatted error message + */ + private static function format_notices_for_error( $notices ) { + $error_messages = []; + + // Prioritize error notices + if ( ! empty( $notices['error'] ) ) { + foreach ( $notices['error'] as $notice ) { + $error_messages[] = $notice['notice'] ?? $notice; + } + } + + // Include other notice types if no errors + if ( empty( $error_messages ) ) { + foreach ( [ 'notice', 'success' ] as $type ) { + if ( ! empty( $notices[ $type ] ) ) { + foreach ( $notices[ $type ] as $notice ) { + $error_messages[] = $notice['notice'] ?? $notice; + } + } + } + } + + return implode( ' ', $error_messages ); + } } diff --git a/includes/type/enum/class-cart-notice-type.php b/includes/type/enum/class-cart-notice-type.php new file mode 100644 index 00000000..7ae6bff2 --- /dev/null +++ b/includes/type/enum/class-cart-notice-type.php @@ -0,0 +1,42 @@ + __( 'WooCommerce notice types', 'wp-graphql-woocommerce' ), + 'values' => [ + 'ERROR' => [ + 'value' => 'error', + 'description' => __( 'Error notice', 'wp-graphql-woocommerce' ), + ], + 'SUCCESS' => [ + 'value' => 'success', + 'description' => __( 'Success notice', 'wp-graphql-woocommerce' ), + ], + 'NOTICE' => [ + 'value' => 'notice', + 'description' => __( 'General notice', 'wp-graphql-woocommerce' ), + ], + ], + ] + ); + } +} \ No newline at end of file diff --git a/includes/type/object/class-cart-notice.php b/includes/type/object/class-cart-notice.php new file mode 100644 index 00000000..96287d1d --- /dev/null +++ b/includes/type/object/class-cart-notice.php @@ -0,0 +1,44 @@ + __( 'A WooCommerce notice', 'wp-graphql-woocommerce' ), + 'fields' => [ + 'type' => [ + 'type' => 'CartNoticeTypeEnum', + 'description' => __( 'Notice type', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $notice ) { + return $notice['type'] ?? null; + }, + ], + 'message' => [ + 'type' => 'String', + 'description' => __( 'Notice message', 'wp-graphql-woocommerce' ), + 'resolve' => static function ( $notice ) { + return $notice['message'] ?? null; + }, + ], + ], + ] + ); + } +} \ No newline at end of file diff --git a/tests/wpunit/CartMutationsTest.php b/tests/wpunit/CartMutationsTest.php index ff892b1f..740c043e 100644 --- a/tests/wpunit/CartMutationsTest.php +++ b/tests/wpunit/CartMutationsTest.php @@ -1260,7 +1260,7 @@ public function testFillCartMutationAndErrors() { 'cartErrors', [ $this->expectedField( 'type', 'INVALID_COUPON' ), - $this->expectedField( 'reasons', [ "Coupon \"{$invalid_coupon}\" does not exist!" ] ), + $this->expectedField( 'reasons', [ "Coupon "{$invalid_coupon}" cannot be applied because it does not exist." ] ), $this->expectedField( 'code', $invalid_coupon ), ] ), diff --git a/tests/wpunit/CheckoutMutationTest.php b/tests/wpunit/CheckoutMutationTest.php index 3ac4b089..a5f6bc0c 100644 --- a/tests/wpunit/CheckoutMutationTest.php +++ b/tests/wpunit/CheckoutMutationTest.php @@ -18,46 +18,31 @@ public function setUp(): void { update_option( 'woocommerce_enable_guest_checkout', 'yes' ); // Enable payment gateways. - update_option( - 'woocommerce_bacs_settings', - [ - 'enabled' => 'yes', - 'title' => 'Direct bank transfer', - 'description' => 'Make your payment directly into our bank account. Please use your Order ID as the payment reference. Your order will not be shipped until the funds have cleared in our account.', - 'instructions' => 'Instructions that will be added to the thank you page and emails.', - 'account' => '', - ] - ); - - update_option( - 'woocommerce_stripe_settings', - [ - 'enabled' => 'yes', - 'title' => 'Credit Card (Stripe)', - 'description' => 'Pay with your credit card via Stripe', - 'webhook' => '', - 'testmode' => 'yes', - 'test_publishable_key' => defined( 'STRIPE_API_PUBLISHABLE_KEY' ) - ? STRIPE_API_PUBLISHABLE_KEY - : getenv( 'STRIPE_API_PUBLISHABLE_KEY' ), - 'test_secret_key' => defined( 'STRIPE_API_SECRET_KEY' ) - ? STRIPE_API_SECRET_KEY - : getenv( 'STRIPE_API_SECRET_KEY' ), - 'test_webhook_secret' => '', - 'publishable_key' => '', - 'secret_key' => '', - 'webhook_secret' => '', - 'inline_cc_form' => 'no', - 'statement_descriptor' => '', - 'capture' => 'yes', - 'payment_request' => 'yes', - 'payment_request_button_type' => 'buy', - 'payment_request_button_theme' => 'dark', - 'payment_request_button_height' => '44', - 'saved_cards' => 'yes', - 'logging' => 'no', - ] + $gateways = \WC()->payment_gateways->payment_gateways(); + $bacs_gateway = $gateways['bacs']; + $bacs_gateway->settings['enabled'] = 'yes'; + update_option( $bacs_gateway->get_option_key(), $bacs_gateway->settings ); + $stripe_settings = WC_Stripe_Helper::get_stripe_settings(); + $stripe_settings['enabled'] = 'yes'; + $stripe_settings['testmode'] = 'yes'; + $stripe_settings['test_publishable_key'] = defined( 'STRIPE_API_PUBLISHABLE_KEY' ) + ? STRIPE_API_PUBLISHABLE_KEY + : getenv( 'STRIPE_API_PUBLISHABLE_KEY' ); + $stripe_settings['test_secret_key'] = defined( 'STRIPE_API_SECRET_KEY' ) + ? STRIPE_API_SECRET_KEY + : getenv( 'STRIPE_API_SECRET_KEY' ); + WC_Stripe_Helper::update_main_stripe_settings( $stripe_settings ); + $_SERVER['HTTPS'] = false; + add_filter( 'wc_stripe_is_upe_checkout_enabled', '__return_false' ); + add_filter( + 'woocommerce_available_payment_gateways', + function( $available_gateways ) { + $stripe_gateway = new WC_Gateway_Stripe(); + $available_gateways[ $stripe_gateway->id ] = $stripe_gateway; + return $available_gateways; + } ); + \WC()->payment_gateways->init(); // Additional cart fees. add_action( diff --git a/tests/wpunit/CheckoutNoticesTest.php b/tests/wpunit/CheckoutNoticesTest.php new file mode 100644 index 00000000..d39344a4 --- /dev/null +++ b/tests/wpunit/CheckoutNoticesTest.php @@ -0,0 +1,258 @@ +loginAs( 0 ); + + // Turn on guest checkout. + update_option( 'woocommerce_enable_guest_checkout', 'yes' ); + + // Enable test gateway that can simulate failures + $gateways = \WC()->payment_gateways->payment_gateways(); + $bacs_gateway = $gateways['bacs']; + $bacs_gateway->settings['enabled'] = 'yes'; + update_option( $bacs_gateway->get_option_key(), $bacs_gateway->settings ); + \WC()->payment_gateways->init(); + } + + private function getCheckoutMutation() { + return ' + mutation checkout( $input: CheckoutInput! ) { + checkout( input: $input ) { + clientMutationId + order { + id + status + } + customer { + id + } + result + redirect + } + } + '; + } + + private function getCheckoutInput( $overwrite = [] ) { + return array_merge( + [ + 'paymentMethod' => 'bacs', + 'billing' => [ + 'firstName' => 'John', + 'lastName' => 'Doe', + 'address1' => '123 Test St', + 'city' => 'Test City', + 'state' => 'NY', + 'postcode' => '12345', + 'country' => 'US', + 'email' => 'test@example.com', + 'phone' => '555-555-1234', + 'overwrite' => true, + ], + ], + $overwrite + ); + } + + /** + * Test that checkout mutation includes WC notices in error messages + * This verifies the fix for GitHub issue #666 + */ + public function testCheckoutMutationIncludesNoticesInErrorMessage() { + // Arrange + $product_id = $this->factory->product->createSimple(); + WC()->cart->add_to_cart( $product_id, 1 ); + + // Hook into checkout process to simulate payment gateway failure + add_action( 'woocommerce_checkout_process', function() { + wc_add_notice( 'Payment failed: Test card declined', 'error' ); + }, 10 ); + + $query = $this->getCheckoutMutation(); + $variables = [ 'input' => $this->getCheckoutInput() ]; + + // Act + $response = $this->graphql( compact( 'query', 'variables' ) ); + + // Assert + $expected = [ $this->expectedField( 'checkout', static::IS_NULL ) ]; + $this->assertQueryError( $response, $expected ); + + // Verify the error message contains our notice text + $this->assertResponseIsValid( $response ); + $this->assertArrayHasKey( 'errors', $response ); + $this->assertStringContainsString( 'Payment failed: Test card declined', $response['errors'][0]['message'] ); + + // Verify notices are cleared from session + $remaining_notices = wc_get_notices(); + $this->assertEmpty( $remaining_notices, 'Notices should be cleared after checkout failure' ); + } + + /** + * Test that checkout mutation now has notices field available + * This verifies the GraphQL schema enhancement + */ + public function testCheckoutMutationHasNoticesField() { + // Arrange + $product_id = $this->factory->product->createSimple(); + WC()->cart->add_to_cart( $product_id, 1 ); + + // Hook to add error notice during checkout + add_action( 'woocommerce_checkout_process', function() { + wc_add_notice( 'Payment failed: Test card declined', 'error' ); + }, 10 ); + + $query = ' + mutation checkout( $input: CheckoutInput! ) { + checkout( input: $input ) { + notices { + type + message + } + result + } + } + '; + + $variables = [ 'input' => $this->getCheckoutInput() ]; + + // Act + $response = $this->graphql( compact( 'query', 'variables' ) ); + + // Assert - The query should not fail due to missing notices field + // Even though checkout fails, the schema should accept the notices field + $this->assertResponseIsValid( $response ); + + // Clean up + wc_clear_notices(); + } + + /** + * Test that notices persist across checkout attempts + * This reproduces the exact issue from GitHub issue #666 + */ + public function testNoticesPersistAcrossCheckoutAttempts() { + // Add a product to cart + $product_id = $this->factory->product->createSimple(); + WC()->cart->add_to_cart( $product_id, 1 ); + + // Hook into checkout validation to simulate payment gateway failure + add_action( 'woocommerce_after_checkout_validation', function() { + // Simulate a payment gateway adding an error notice during validation + wc_add_notice( 'Previous payment failed: Test error', 'error' ); + }, 10 ); + + $variables = [ 'input' => $this->getCheckoutInput() ]; + $query = $this->getCheckoutMutation(); + + // This should fail due to the error notice added during validation + $response = $this->graphql( compact( 'query', 'variables' ) ); + + // The checkout should fail due to the error notice + $this->assertQueryError( $response ); + + // Clean up + wc_clear_notices(); + } + + /** + * Test that successful checkout returns non-error notices + */ + public function testSuccessfulCheckoutReturnsNotices() { + // Arrange + $product_id = $this->factory->product->createSimple(); + WC()->cart->add_to_cart( $product_id, 1 ); + + // Hook into checkout process to add a success notice + add_action( 'woocommerce_checkout_order_processed', function() { + wc_add_notice( 'Order processed successfully!', 'success' ); + }, 10 ); + + $query = ' + mutation checkout( $input: CheckoutInput! ) { + checkout( input: $input ) { + notices { + type + message + } + order { + id + } + result + } + } + '; + + $variables = [ 'input' => $this->getCheckoutInput() ]; + + // Act + $response = $this->graphql( compact( 'query', 'variables' ) ); + + // Assert + $expected = [ + $this->expectedField( 'checkout.result', 'success' ), + $this->expectedField( 'checkout.order.id', static::NOT_NULL ), + $this->expectedNode( + 'checkout.notices', + [ + $this->expectedField( 'type', 'SUCCESS' ), + $this->expectedField( 'message', 'Order processed successfully!' ), + ] + ), + ]; + + $this->assertQuerySuccessful( $response, $expected ); + + // Verify notices are cleared from session + $remaining_notices = wc_get_notices(); + $this->assertEmpty( $remaining_notices, 'Notices should be cleared after successful checkout' ); + } + + /** + * Test comparing checkout mutation with session update mutation notice handling + * This shows how other mutations properly handle notices + */ + public function testSessionUpdateMutationHandlesNoticesProperly() { + // Arrange + wc_add_notice( 'Test error notice', 'error' ); + + $query = ' + mutation updateSession( $input: UpdateSessionInput! ) { + updateSession( input: $input ) { + session { + key + value + } + customer { + id + } + } + } + '; + + $variables = [ + 'input' => [ + 'sessionData' => [ + [ + 'key' => 'test_key', + 'value' => 'test_value' + ] + ] + ] + ]; + + // Act + $response = $this->graphql( compact( 'query', 'variables' ) ); + + // Assert + $expected = [ $this->expectedField( 'updateSession', static::IS_NULL ) ]; + $this->assertQueryError( $response, $expected ); + + // Verify notices are cleared after the mutation + $notices = wc_get_notices(); + $this->assertEmpty( $notices, 'Session update mutation should clear notices' ); + } +} \ No newline at end of file