Repository: saleor/saleor

Issue:
Allow to remove shipping method
Current state:
Once the Shipping Method is assigned to the checkout, it cannot be removed by the user.

DoD:
make `checkoutShippingMethodUpdate.shippingMethodId` argument nullable
if null is passed as a value, remove the shipping method from the checkout


Discussion:

Resolving patch:
diff --git a/saleor/graphql/checkout/mutations/checkout_shipping_method_update.py b/saleor/graphql/checkout/mutations/checkout_shipping_method_update.py
--- a/saleor/graphql/checkout/mutations/checkout_shipping_method_update.py
+++ b/saleor/graphql/checkout/mutations/checkout_shipping_method_update.py
@@ -48,7 +48,9 @@ class Arguments:
                 f"The ID of the checkout. {DEPRECATED_IN_3X_INPUT} Use `id` instead."
             ),
         )
-        shipping_method_id = graphene.ID(required=True, description="Shipping method.")
+        shipping_method_id = graphene.ID(
+            required=False, default_value=None, description="Shipping method."
+        )
 
     class Meta:
         description = "Updates the shipping method of the checkout."
@@ -91,7 +93,7 @@ def _resolve_delivery_method_type(id_) -> Optional[str]:
         return str_type
 
     @classmethod
-    def perform_mutation(  # type: ignore[override]
+    def perform_mutation(
         cls,
         _root,
         info: ResolveInfo,
@@ -99,7 +101,7 @@ def perform_mutation(  # type: ignore[override]
         *,
         checkout_id=None,
         id=None,
-        shipping_method_id,
+        shipping_method_id=None,
         token=None,
     ):
         checkout = get_checkout(cls, info, checkout_id=checkout_id, token=token, id=id)
@@ -131,6 +133,8 @@ def perform_mutation(  # type: ignore[override]
                     )
                 }
             )
+        if shipping_method_id is None:
+            return cls.remove_shipping_method(checkout, checkout_info, lines, manager)
 
         type_name = cls._resolve_delivery_method_type(shipping_method_id)
 
@@ -253,3 +257,21 @@ def perform_on_external_shipping_method(
         cls.call_event(manager.checkout_updated, checkout)
 
         return CheckoutShippingMethodUpdate(checkout=checkout)
+
+    @classmethod
+    def remove_shipping_method(cls, checkout, checkout_info, lines, manager):
+        checkout.shipping_method = None
+        delete_external_shipping_id(checkout=checkout)
+        invalidate_prices_updated_fields = invalidate_checkout_prices(
+            checkout_info, lines, manager, save=False
+        )
+        checkout.save(
+            update_fields=[
+                "shipping_method",
+            ]
+            + invalidate_prices_updated_fields
+        )
+        get_checkout_metadata(checkout).save()
+
+        cls.call_event(manager.checkout_updated, checkout)
+        return CheckoutShippingMethodUpdate(checkout=checkout)


Test patch:
diff --git a/saleor/graphql/checkout/tests/benchmark/test_checkout_mutations.py b/saleor/graphql/checkout/tests/benchmark/test_checkout_mutations.py
--- a/saleor/graphql/checkout/tests/benchmark/test_checkout_mutations.py
+++ b/saleor/graphql/checkout/tests/benchmark/test_checkout_mutations.py
@@ -448,7 +448,7 @@ def test_add_shipping_to_checkout(
         FRAGMENT_CHECKOUT
         + """
             mutation updateCheckoutShippingOptions(
-              $id: ID, $shippingMethodId: ID!
+              $id: ID, $shippingMethodId: ID
             ) {
               checkoutShippingMethodUpdate(
                 id: $id, shippingMethodId: $shippingMethodId
diff --git a/saleor/graphql/checkout/tests/mutations/test_checkout_shipping_method_update.py b/saleor/graphql/checkout/tests/mutations/test_checkout_shipping_method_update.py
--- a/saleor/graphql/checkout/tests/mutations/test_checkout_shipping_method_update.py
+++ b/saleor/graphql/checkout/tests/mutations/test_checkout_shipping_method_update.py
@@ -21,7 +21,7 @@
 
 MUTATION_UPDATE_SHIPPING_METHOD = """
     mutation checkoutShippingMethodUpdate(
-            $id: ID, $shippingMethodId: ID!){
+            $id: ID, $shippingMethodId: ID){
         checkoutShippingMethodUpdate(
             id: $id, shippingMethodId: $shippingMethodId) {
             errors {
diff --git a/saleor/graphql/checkout/tests/mutations/test_checkout_shipping_method_update_nullable_shipping_method_id.py b/saleor/graphql/checkout/tests/mutations/test_checkout_shipping_method_update_nullable_shipping_method_id.py
new file mode 100644
--- /dev/null
+++ b/saleor/graphql/checkout/tests/mutations/test_checkout_shipping_method_update_nullable_shipping_method_id.py
@@ -0,0 +1,100 @@
+from unittest.mock import patch
+
+import graphene
+import pytest
+
+from ....core.utils import to_global_id_or_none
+from ....tests.utils import get_graphql_content
+
+MUTATION_UPDATE_SHIPPING_METHOD = """
+    mutation checkoutShippingMethodUpdate(
+        $id: ID, $shippingMethodId: ID
+    ) {
+        checkoutShippingMethodUpdate(
+            id: $id, shippingMethodId: $shippingMethodId
+        ) {
+            errors {
+                field
+                message
+                code
+            }
+            checkout {
+                id
+                token
+                voucherCode
+                shippingMethod {
+                    id
+                }
+                shippingPrice {
+                    net {
+                        amount
+                    }
+                    gross {
+                        amount
+                    }
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.django_db
+@patch(
+    "saleor.graphql.checkout.mutations.checkout_shipping_method_update."
+    "clean_delivery_method"
+)
+def test_checkout_shipping_method_update_nullable_shipping_method_id(
+    mock_clean_delivery_method,
+    staff_api_client,
+    shipping_method,
+    checkout_with_item_and_voucher_and_shipping_method,
+):
+    # Set up the initial state of the checkout
+    checkout = checkout_with_item_and_voucher_and_shipping_method
+
+    # Mock the clean_delivery_method function
+    mock_clean_delivery_method.return_value = True
+
+    # Execute the mutation to remove the shipping method from the checkout
+    response = staff_api_client.post_graphql(
+        MUTATION_UPDATE_SHIPPING_METHOD,
+        variables={"id": to_global_id_or_none(checkout), "shippingMethodId": None},
+    )
+
+    # Assert that the mutation was successful
+    data = get_graphql_content(response)["data"]["checkoutShippingMethodUpdate"]
+    errors = data["errors"]
+    assert not errors
+    assert data["checkout"]["shippingMethod"] is None
+
+    # Ensure the shipping method was removed from the checkout
+    checkout.refresh_from_db(fields=["shipping_method"])
+    assert checkout.shipping_method is None
+
+    # Assert that the shipping price is cleared
+    assert checkout.shipping_price.net.amount == 0
+    assert checkout.shipping_price.gross.amount == 0
+
+    # Assert that the voucher is still assigned to the checkout
+    assert checkout.voucher_code is not None
+
+    # Add the shipping method back to the checkout
+    response = staff_api_client.post_graphql(
+        MUTATION_UPDATE_SHIPPING_METHOD,
+        variables={
+            "id": to_global_id_or_none(checkout),
+            "shippingMethodId": graphene.Node.to_global_id(
+                "ShippingMethod", shipping_method.pk
+            ),
+        },
+    )
+
+    # Assert that the mutation was successful
+    data = get_graphql_content(response)["data"]["checkoutShippingMethodUpdate"]
+    errors = data["errors"]
+    assert not errors
+
+    # Ensure the shipping method was added back to the checkout
+    checkout.refresh_from_db(fields=["shipping_method"])
+    assert checkout.shipping_method is not None
diff --git a/saleor/graphql/checkout/tests/test_checkout_price_expiration.py b/saleor/graphql/checkout/tests/test_checkout_price_expiration.py
--- a/saleor/graphql/checkout/tests/test_checkout_price_expiration.py
+++ b/saleor/graphql/checkout/tests/test_checkout_price_expiration.py
@@ -271,7 +271,7 @@ def test_checkout_billing_address_update_invalidate_prices(
 
 
 UPDATE_CHECKOUT_SHIPPING_METHOD = """
-mutation updateCheckoutShippingOptions($token: UUID!, $shippingMethodId: ID!) {
+mutation updateCheckoutShippingOptions($token: UUID!, $shippingMethodId: ID) {
   checkoutShippingMethodUpdate(token: $token, shippingMethodId: $shippingMethodId) {
     errors {
       field
diff --git a/saleor/tests/fixtures.py b/saleor/tests/fixtures.py
--- a/saleor/tests/fixtures.py
+++ b/saleor/tests/fixtures.py
@@ -485,6 +485,16 @@ def checkout_with_item_and_shipping_method(checkout_with_item, shipping_method):
     return checkout
 
 
+@pytest.fixture
+def checkout_with_item_and_voucher_and_shipping_method(
+    checkout_with_item_and_voucher, shipping_method
+):
+    checkout = checkout_with_item_and_voucher
+    checkout.shipping_method = shipping_method
+    checkout.save()
+    return checkout
+
+
 @pytest.fixture
 def other_shipping_method(shipping_zone, channel_USD):
     method = ShippingMethod.objects.create(