Repository: saleor/saleor

Issue:
Bug: Problem with missing orders for adyen payments
### What are you trying to achieve?

When something goes wrong during order creation the refund or void should be always sent. This situation does not happen for the situation when the checkout line variant becomes invalid or the chosen shipping method is not valid anymore. In such cases, users end up with a finished payment and without an order.

### Steps to reproduce the problem

1. Create a checkout
2. Choose adyen payment method with additional actions - for example klarna
3. Complete checkout
4. When the additional window appeared, make the chosen shipping method invalid (e.g. remove channel listing or minimal price/weight)
5. You will end up with no order and captured payment. (Fetch the checkout again with the payment and transactions to see this)

### What did you expect to happen?

When something goes wrong during order creation we should run refund or void.

### Logs

_No response_

### Environment

Saleor version: 3.1+



Discussion:

Resolving patch:
diff --git a/saleor/checkout/complete_checkout.py b/saleor/checkout/complete_checkout.py
--- a/saleor/checkout/complete_checkout.py
+++ b/saleor/checkout/complete_checkout.py
@@ -696,15 +696,19 @@ def complete_checkout(
     checkout = checkout_info.checkout
     channel_slug = checkout_info.channel.slug
     payment = checkout.get_last_active_payment()
-    _prepare_checkout(
-        manager=manager,
-        checkout_info=checkout_info,
-        lines=lines,
-        discounts=discounts,
-        tracking_code=tracking_code,
-        redirect_url=redirect_url,
-        payment=payment,
-    )
+    try:
+        _prepare_checkout(
+            manager=manager,
+            checkout_info=checkout_info,
+            lines=lines,
+            discounts=discounts,
+            tracking_code=tracking_code,
+            redirect_url=redirect_url,
+            payment=payment,
+        )
+    except ValidationError as exc:
+        gateway.payment_refund_or_void(payment, manager, channel_slug=channel_slug)
+        raise exc
 
     if site_settings is None:
         site_settings = Site.objects.get_current().settings
diff --git a/saleor/payment/gateways/adyen/webhooks.py b/saleor/payment/gateways/adyen/webhooks.py
--- a/saleor/payment/gateways/adyen/webhooks.py
+++ b/saleor/payment/gateways/adyen/webhooks.py
@@ -176,6 +176,7 @@ def create_order(payment, checkout, manager):
         discounts = fetch_active_discounts()
         lines, unavailable_variant_pks = fetch_checkout_lines(checkout)
         if unavailable_variant_pks:
+            payment_refund_or_void(payment, manager, checkout.channel.slug)
             raise ValidationError(
                 "Some of the checkout lines variants are unavailable."
             )


Test patch:
diff --git a/saleor/checkout/tests/test_checkout_complete.py b/saleor/checkout/tests/test_checkout_complete.py
--- a/saleor/checkout/tests/test_checkout_complete.py
+++ b/saleor/checkout/tests/test_checkout_complete.py
@@ -2,6 +2,7 @@
 
 import pytest
 from django.contrib.auth.models import AnonymousUser
+from django.core.exceptions import ValidationError
 from django.test import override_settings
 
 from ...account import CustomerEvents
@@ -1254,3 +1255,67 @@ def test_complete_checkout_action_required_voucher_once_per_customer(
     assert action_required is True
     assert not voucher_customer.exists()
     mocked_create_order.assert_not_called()
+
+
+@mock.patch("saleor.payment.gateway.payment_refund_or_void")
+def test_complete_checkout_invalid_shipping_method(
+    mocked_payment_refund_or_void,
+    voucher,
+    customer_user,
+    checkout_ready_to_complete,
+    app,
+    payment_txn_to_confirm,
+):
+    """Ensure that when an error in _prepare_checkout method is raised
+    the method for refund or void is called."""
+    # given
+    checkout = checkout_ready_to_complete
+
+    payment = Payment.objects.create(
+        gateway="mirumee.payments.dummy", is_active=True, checkout=checkout
+    )
+    payment.to_confirm = True
+    payment.save()
+
+    checkout.user = customer_user
+    checkout.billing_address = customer_user.default_billing_address
+    checkout.shipping_address = customer_user.default_billing_address
+    checkout.tracking_code = ""
+    checkout.redirect_url = "https://www.example.com"
+    checkout.voucher_code = voucher.code
+    checkout.save()
+
+    # make the current shipping method invalid
+    checkout.shipping_method.channel_listings.filter(channel=checkout.channel).delete()
+
+    voucher.apply_once_per_customer = True
+    voucher.save()
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, [], manager)
+
+    # when
+    with pytest.raises(ValidationError):
+        order, action_required, _ = complete_checkout(
+            checkout_info=checkout_info,
+            manager=manager,
+            lines=lines,
+            payment_data={},
+            store_source=False,
+            discounts=None,
+            user=customer_user,
+            app=app,
+        )
+
+        # then
+        voucher_customer = VoucherCustomer.objects.filter(
+            voucher=voucher, customer_email=customer_user.email
+        )
+        assert not order
+        assert action_required is True
+        assert not voucher_customer.exists()
+
+    mocked_payment_refund_or_void.called_once_with(
+        payment, manager, channel_slug=checkout.channel.slug
+    )
diff --git a/saleor/payment/gateways/adyen/tests/webhooks/test_handle_additional_actions.py b/saleor/payment/gateways/adyen/tests/webhooks/test_handle_additional_actions.py
--- a/saleor/payment/gateways/adyen/tests/webhooks/test_handle_additional_actions.py
+++ b/saleor/payment/gateways/adyen/tests/webhooks/test_handle_additional_actions.py
@@ -786,3 +786,63 @@ def test_handle_additional_actions_lack_of_parameter_in_request(
         response.content.decode()
         == "Cannot perform payment. Lack of required parameters in request."
     )
+
+
+@mock.patch("saleor.payment.gateways.adyen.webhooks.payment_refund_or_void")
+@mock.patch("saleor.payment.gateways.adyen.webhooks.api_call")
+def test_handle_additional_actions_unavailable_variants(
+    api_call_mock, payment_refund_or_void, payment_adyen_for_checkout, adyen_plugin
+):
+    # given
+    plugin = adyen_plugin()
+
+    channel_slug = plugin.channel.slug
+    payment_adyen_for_checkout.to_confirm = True
+    payment_adyen_for_checkout.extra_data = json.dumps(
+        [{"payment_data": "test_data", "parameters": ["payload"]}]
+    )
+    payment_adyen_for_checkout.save(update_fields=["to_confirm", "extra_data"])
+
+    transaction_count = payment_adyen_for_checkout.transactions.all().count()
+
+    checkout = payment_adyen_for_checkout.checkout
+    payment_id = graphene.Node.to_global_id("Payment", payment_adyen_for_checkout.pk)
+    checkout_id = graphene.Node.to_global_id("Checkout", checkout.pk)
+
+    # make the checkout line unavailable
+    checkout.lines.first().variant.channel_listings.filter(
+        channel=checkout.channel
+    ).delete()
+
+    request_mock = mock.Mock()
+    request_mock.GET = {"payment": payment_id, "checkout": str(checkout.pk)}
+    request_mock.POST = {"payload": "test"}
+
+    payment_details_mock = mock.Mock()
+    message = {
+        "pspReference": "11111",
+        "resultCode": "Test",
+    }
+    api_call_mock.return_value.message = message
+
+    # when
+    response = handle_additional_actions(
+        request_mock, payment_details_mock, channel_slug
+    )
+
+    # then
+    payment_adyen_for_checkout.refresh_from_db()
+    assert response.status_code == 302
+    assert f"checkout={quote_plus(checkout_id)}" in response.url
+    assert f"resultCode={message['resultCode']}" in response.url
+    assert f"payment={quote_plus(payment_id)}" in response.url
+    transactions = payment_adyen_for_checkout.transactions.all()
+    assert transactions.count() == transaction_count + 2  # TO_CONFIRM, AUTH
+
+    assert transactions.first().kind == TransactionKind.ACTION_TO_CONFIRM
+    assert transactions.last().kind == TransactionKind.AUTH
+    assert payment_adyen_for_checkout.order is None
+    assert payment_adyen_for_checkout.checkout
+    payment_refund_or_void.assert_called_once_with(
+        payment_adyen_for_checkout, mock.ANY, checkout.channel.slug
+    )