Repository: saleor/saleor

Issue:
Bug: The `checkoutPaymentCreate` can be run during `checkoutComplete` performing
### What are you trying to achieve?

The `checkoutPaymentCreate` can be run during `checkoutComplete` performing, it can cause the deactivation of the payment that is currently performed in the `checkoutComplete` mutation - we should prevent such a situation.


### Steps to reproduce the problem

It can be hard to reproduce as the `checkoutPaymentCreate` mutation need to be called during `process_payment` calls in `checkoutComplete` mutation, but the steps are:
1. Create checkout
2. Update shipping method if needed
3. Run `checkoutPaymentCreate` the payment object will be created
4. Run zcheckoutComplete` mutation
5. In the meantime, when waiting for a response from the gateway, run `checkoutPaymentCreate` once again.
6. It should end up with a situation that the payment was process for inactive payment, the second uncharged payment will be active.

![image](https://user-images.githubusercontent.com/40886528/199694038-feb5c5fb-a16d-47d3-bb0e-5326badfeeee.png)


### What did you expect to happen?

Running `checkoutPaymentCreate` should be blocked when `checkoutComplete` is run.

### 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
@@ -727,6 +727,10 @@ def complete_checkout(
 
     action_required = False
     action_data: Dict[str, str] = {}
+    # Fetch the checkout with a lock just to ensure that no payment is created
+    # for this checkout right now.
+    with transaction.atomic():
+        (Checkout.objects.select_for_update().filter(pk=checkout.pk).first())
     if payment:
         txn = _process_payment(
             payment=payment,  # type: ignore
@@ -738,6 +742,17 @@ def complete_checkout(
             channel_slug=channel_slug,
         )
 
+        # As payment processing might take a while, we need to check if the payment
+        # doesn't become inactive in the meantime. If it's inactive we need to refund
+        # the payment.
+        payment.refresh_from_db()
+        if not payment.is_active:
+            gateway.payment_refund_or_void(payment, manager, channel_slug=channel_slug)
+            raise ValidationError(
+                f"The payment with pspReference: {payment.psp_reference} is inactive.",
+                code=CheckoutErrorCode.INACTIVE_PAYMENT.value,
+            )
+
         if txn.customer_id and user.is_authenticated:
             store_customer_id(user, payment.gateway, txn.customer_id)  # type: ignore
 
diff --git a/saleor/checkout/error_codes.py b/saleor/checkout/error_codes.py
--- a/saleor/checkout/error_codes.py
+++ b/saleor/checkout/error_codes.py
@@ -29,3 +29,4 @@ class CheckoutErrorCode(Enum):
     UNAVAILABLE_VARIANT_IN_CHANNEL = "unavailable_variant_in_channel"
     EMAIL_NOT_SET = "email_not_set"
     NO_LINES = "no_lines"
+    INACTIVE_PAYMENT = "inactive_payment"
diff --git a/saleor/graphql/payment/mutations.py b/saleor/graphql/payment/mutations.py
--- a/saleor/graphql/payment/mutations.py
+++ b/saleor/graphql/payment/mutations.py
@@ -1,9 +1,11 @@
-from typing import TYPE_CHECKING, List
+from typing import List
 
 import graphene
 from django.core.exceptions import ValidationError
+from django.db import transaction
 
 from ...channel.models import Channel
+from ...checkout import models as checkout_models
 from ...checkout.calculations import calculate_checkout_total_with_gift_cards
 from ...checkout.checkout_cleaner import clean_billing_address, clean_checkout_shipping
 from ...checkout.fetch import fetch_checkout_info, fetch_checkout_lines
@@ -30,9 +32,6 @@
 from .types import Payment, PaymentInitialized
 from .utils import metadata_contains_empty_key
 
-if TYPE_CHECKING:
-    from ...checkout import models as checkout_models
-
 
 def description(enum):
     if enum is None:
@@ -275,31 +274,51 @@ def perform_mutation(cls, _root, info, checkout_id=None, token=None, **data):
             "customer_user_agent": info.context.META.get("HTTP_USER_AGENT"),
         }
 
-        cancel_active_payments(checkout)
-
         metadata = data.get("metadata")
 
         if metadata is not None:
             cls.validate_metadata_keys(metadata)
             metadata = {data.key: data.value for data in metadata}
 
-        payment = None
-        if amount != 0:
-            payment = create_payment(
-                gateway=gateway,
-                payment_token=data.get("token", ""),
-                total=amount,
-                currency=checkout.currency,
-                email=checkout.get_customer_email(),
-                extra_data=extra_data,
-                # FIXME this is not a customer IP address. It is a client storefront ip
-                customer_ip_address=get_client_ip(info.context),
-                checkout=checkout,
-                return_url=data.get("return_url"),
-                store_payment_method=data["store_payment_method"],
-                metadata=metadata,
+        # The payment creation and deactivation of old payments should happened in the
+        # transaction to avoid creating multiple active payments.
+        with transaction.atomic():
+            # The checkout lock is used to prevent processing checkout completion
+            # and new payment creation. This kind of case could result in the missing
+            # payments, that were created for the checkout that was already converted
+            # to an order.
+            checkout = (
+                checkout_models.Checkout.objects.select_for_update()
+                .filter(pk=checkout_info.checkout.pk)
+                .first()
             )
 
+            if not checkout:
+                raise ValidationError(
+                    "Checkout doesn't exist anymore.",
+                    code=PaymentErrorCode.NOT_FOUND.value,
+                )
+
+            cancel_active_payments(checkout)
+
+            payment = None
+            if amount != 0:
+                payment = create_payment(
+                    gateway=gateway,
+                    payment_token=data.get("token", ""),
+                    total=amount,
+                    currency=checkout.currency,
+                    email=checkout.get_customer_email(),
+                    extra_data=extra_data,
+                    # FIXME this is not a customer IP address.
+                    # It is a client storefront ip
+                    customer_ip_address=get_client_ip(info.context),
+                    checkout=checkout,
+                    return_url=data.get("return_url"),
+                    store_payment_method=data["store_payment_method"],
+                    metadata=metadata,
+                )
+
         return CheckoutPaymentCreate(payment=payment, checkout=checkout)
 
 


Test patch:
diff --git a/saleor/graphql/checkout/tests/test_checkout_complete.py b/saleor/graphql/checkout/tests/test_checkout_complete.py
--- a/saleor/graphql/checkout/tests/test_checkout_complete.py
+++ b/saleor/graphql/checkout/tests/test_checkout_complete.py
@@ -3,6 +3,7 @@
 from decimal import Decimal
 from unittest.mock import ANY, patch
 
+import before_after
 import graphene
 import pytest
 from django.contrib.auth.models import AnonymousUser
@@ -2490,3 +2491,76 @@ def test_checkout_complete_0_total_value_from_giftcard(
     assert not Checkout.objects.filter(
         pk=checkout.pk
     ).exists(), "Checkout should have been deleted"
+
+
+@pytest.mark.django_db(transaction=True)
+def test_checkout_complete_payment_create_create_run_in_meantime(
+    site_settings,
+    user_api_client,
+    checkout_without_shipping_required,
+    gift_card,
+    payment_dummy,
+    address,
+    shipping_method,
+):
+    # given
+    checkout = checkout_without_shipping_required
+    checkout.shipping_address = address
+    checkout.shipping_method = shipping_method
+    checkout.billing_address = address
+    checkout.store_value_in_metadata(items={"accepted": "true"})
+    checkout.store_value_in_private_metadata(items={"accepted": "false"})
+    checkout.save()
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, [], manager)
+    total = calculations.calculate_checkout_total_with_gift_cards(
+        manager, checkout_info, lines, address
+    )
+    site_settings.automatically_confirm_all_new_orders = True
+    site_settings.save()
+    payment = payment_dummy
+    payment.is_active = True
+    payment.order = None
+    payment.total = total.gross.amount
+    payment.currency = total.gross.currency
+    payment.checkout = checkout
+    payment.save()
+    assert not payment.transactions.exists()
+
+    redirect_url = "https://www.example.com"
+    variables = {"token": checkout.token, "redirectUrl": redirect_url}
+
+    # Call CheckoutPaymentCreate mutation during the CheckoutComplete call processing.
+    # It should cause deactivation of current payment and creation of new one.
+    def call_payment_create_mutation(*args, **kwargs):
+        from ...payment.tests.mutations.test_payment_create import (
+            CREATE_PAYMENT_MUTATION,
+            DUMMY_GATEWAY,
+        )
+
+        variables = {
+            "token": checkout.token,
+            "input": {
+                "gateway": DUMMY_GATEWAY,
+                "token": "sample-token",
+                "amount": total.gross.amount,
+            },
+        }
+
+        user_api_client.post_graphql(CREATE_PAYMENT_MUTATION, variables)
+
+    # when
+    with before_after.before(
+        "saleor.checkout.complete_checkout._get_order_data",
+        call_payment_create_mutation,
+    ):
+        response = user_api_client.post_graphql(MUTATION_CHECKOUT_COMPLETE, variables)
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["checkoutComplete"]
+    errors = data["errors"]
+    assert len(errors) == 1
+    assert errors[0]["code"] == CheckoutErrorCode.INACTIVE_PAYMENT.name
diff --git a/saleor/graphql/payment/tests/mutations/test_payment_create.py b/saleor/graphql/payment/tests/mutations/test_payment_create.py
--- a/saleor/graphql/payment/tests/mutations/test_payment_create.py
+++ b/saleor/graphql/payment/tests/mutations/test_payment_create.py
@@ -1,5 +1,6 @@
 from decimal import Decimal
 
+import before_after
 import graphene
 import pytest
 
@@ -583,3 +584,46 @@ def test_checkout_add_payment_checkout_without_lines(
     assert len(errors) == 1
     assert errors[0]["field"] == "lines"
     assert errors[0]["code"] == PaymentErrorCode.NO_CHECKOUT_LINES.name
+
+
+def test_checkout_add_payment_run_multiple_times(
+    user_api_client, checkout_without_shipping_required, address
+):
+    # given
+    checkout = checkout_without_shipping_required
+    checkout.billing_address = address
+    checkout.save()
+
+    manager = get_plugins_manager()
+    lines, _ = fetch_checkout_lines(checkout)
+    checkout_info = fetch_checkout_info(checkout, lines, [], manager)
+    total = calculations.checkout_total(
+        manager=manager, checkout_info=checkout_info, lines=lines, address=address
+    )
+    variables = {
+        "token": checkout.token,
+        "input": {
+            "gateway": DUMMY_GATEWAY,
+            "token": "sample-token",
+            "amount": total.gross.amount,
+        },
+    }
+
+    # call CheckoutPaymentCreate mutation during the first mutation call processing
+    def call_payment_create_mutation(*args, **kwargs):
+        user_api_client.post_graphql(CREATE_PAYMENT_MUTATION, variables)
+
+    # when
+    with before_after.before(
+        "saleor.graphql.payment.mutations.cancel_active_payments",
+        call_payment_create_mutation,
+    ):
+        response = user_api_client.post_graphql(CREATE_PAYMENT_MUTATION, variables)
+
+    # then
+    content = get_graphql_content(response)
+    data = content["data"]["checkoutPaymentCreate"]
+    assert not data["errors"]
+    payments = Payment.objects.filter(checkout=checkout)
+    assert payments.count() == 2
+    assert payments.filter(is_active=True).count() == 1
diff --git a/saleor/tests/fixtures.py b/saleor/tests/fixtures.py
--- a/saleor/tests/fixtures.py
+++ b/saleor/tests/fixtures.py
@@ -2617,7 +2617,7 @@ def product_without_shipping(category, warehouse, channel_USD):
         visible_in_listings=True,
         available_for_purchase=datetime.date(1999, 1, 1),
     )
-    variant = ProductVariant.objects.create(product=product, sku="SKU_B")
+    variant = ProductVariant.objects.create(product=product, sku="SKU_E")
     ProductVariantChannelListing.objects.create(
         variant=variant,
         channel=channel_USD,