Repository: saleor/saleor

Issue:
User cannot checkout without voucher
As side effect from #4154, it was noticed that users would not be able to use a checkout code without encountering a server error when placing their order.



Discussion:

Resolving patch:
diff --git a/saleor/checkout/utils.py b/saleor/checkout/utils.py
--- a/saleor/checkout/utils.py
+++ b/saleor/checkout/utils.py
@@ -26,6 +26,7 @@
 from ..discount import VoucherType
 from ..discount.models import NotApplicable, Voucher
 from ..discount.utils import (
+    decrease_voucher_usage,
     get_products_voucher_discount,
     get_shipping_voucher_discount,
     get_value_voucher_discount,
@@ -747,13 +748,16 @@ def get_voucher_discount_for_checkout(voucher, checkout):
     raise NotImplementedError("Unknown discount type")
 
 
-def get_voucher_for_checkout(checkout, vouchers=None):
+def get_voucher_for_checkout(checkout, vouchers=None, with_lock=False):
     """Return voucher with voucher code saved in checkout if active or None."""
     if checkout.voucher_code is not None:
         if vouchers is None:
             vouchers = Voucher.objects.active(date=date.today())
         try:
-            return vouchers.get(code=checkout.voucher_code)
+            qs = vouchers
+            if with_lock:
+                qs = vouchers.select_for_update()
+            return qs.get(code=checkout.voucher_code)
         except Voucher.DoesNotExist:
             return None
     return None
@@ -906,14 +910,15 @@ def clear_shipping_method(checkout):
     checkout.save(update_fields=["shipping_method"])
 
 
-def _process_voucher_data_for_order(checkout):
+def _get_voucher_data_for_order(checkout):
     """
     Fetch, process and return voucher/discount data from checkout.
 
+    Careful! It should be called inside a transaction.
+
     :raises NotApplicable: When the voucher is not applicable in the current checkout.
     """
-    vouchers = Voucher.objects.active(date=date.today()).select_for_update()
-    voucher = get_voucher_for_checkout(checkout, vouchers)
+    voucher = get_voucher_for_checkout(checkout, with_lock=True)
 
     if checkout.voucher_code and not voucher:
         msg = pgettext(
@@ -1024,7 +1029,6 @@ def prepare_order_data(
     """
     order_data = {}
 
-    order_data.update(_process_voucher_data_for_order(checkout))
     order_data.update(_process_shipping_data_for_order(checkout, taxes))
     order_data.update(_process_user_data_for_order(checkout))
     order_data.update(
@@ -1048,6 +1052,9 @@ def prepare_order_data(
     # validate checkout gift cards
     validate_gift_cards(checkout)
 
+    # Get voucher data (last) as they require a transaction
+    order_data.update(_get_voucher_data_for_order(checkout))
+
     # assign gift cards to the order
     order_data["total_price_left"] = (
         checkout.get_subtotal(discounts, taxes)
@@ -1058,6 +1065,11 @@ def prepare_order_data(
     return order_data
 
 
+def abort_order_data(order_data: dict):
+    if "voucher" in order_data:
+        decrease_voucher_usage(order_data["voucher"])
+
+
 @transaction.atomic
 def create_order(*, checkout: Checkout, order_data: dict, user: User) -> Order:
     """Create an order from the checkout.
@@ -1080,6 +1092,7 @@ def create_order(*, checkout: Checkout, order_data: dict, user: User) -> Order:
 
     total_price_left = order_data.pop("total_price_left")
     order_lines = order_data.pop("lines")
+
     order = Order.objects.create(**order_data, checkout_token=checkout.token)
     order.lines.set(order_lines, bulk=False)
 
diff --git a/saleor/checkout/views/summary.py b/saleor/checkout/views/summary.py
--- a/saleor/checkout/views/summary.py
+++ b/saleor/checkout/views/summary.py
@@ -1,4 +1,5 @@
 from django.contrib import messages
+from django.db import transaction
 from django.shortcuts import redirect
 from django.template.response import TemplateResponse
 from django.utils.translation import pgettext
@@ -19,6 +20,7 @@
 )
 
 
+@transaction.atomic()
 def _handle_order_placement(request, checkout):
     """Try to create an order and redirect the user as necessary.
 
diff --git a/saleor/graphql/checkout/mutations.py b/saleor/graphql/checkout/mutations.py
--- a/saleor/graphql/checkout/mutations.py
+++ b/saleor/graphql/checkout/mutations.py
@@ -7,6 +7,7 @@
 
 from ...checkout import models
 from ...checkout.utils import (
+    abort_order_data,
     add_promo_code_to_checkout,
     add_variant_to_checkout,
     add_voucher_to_checkout,
@@ -502,17 +503,18 @@ def perform_mutation(cls, _root, info, checkout_id):
 
         payment = checkout.get_last_active_payment()
 
-        try:
-            order_data = prepare_order_data(
-                checkout=checkout,
-                tracking_code=analytics.get_client_id(info.context),
-                discounts=info.context.discounts,
-                taxes=taxes,
-            )
-        except InsufficientStock as e:
-            raise ValidationError(f"Insufficient product stock: {e.item}")
-        except voucher_model.NotApplicable:
-            raise ValidationError("Voucher not applicable")
+        with transaction.atomic():
+            try:
+                order_data = prepare_order_data(
+                    checkout=checkout,
+                    tracking_code=analytics.get_client_id(info.context),
+                    discounts=info.context.discounts,
+                    taxes=taxes,
+                )
+            except InsufficientStock as e:
+                raise ValidationError(f"Insufficient product stock: {e.item}")
+            except voucher_model.NotApplicable:
+                raise ValidationError("Voucher not applicable")
 
         try:
             billing_address = order_data["billing_address"]  # type: models.Address
@@ -524,6 +526,7 @@ def perform_mutation(cls, _root, info, checkout_id):
                 shipping_address=AddressData(**shipping_address.as_data()),
             )
         except PaymentError as e:
+            abort_order_data(order_data)
             raise ValidationError(str(e))
 
         # create the order into the database


Test patch:
diff --git a/tests/api/test_checkout.py b/tests/api/test_checkout.py
--- a/tests/api/test_checkout.py
+++ b/tests/api/test_checkout.py
@@ -941,7 +941,8 @@ def test_checkout_complete(
 def test_checkout_complete_does_not_delete_checkout_after_unsuccessful_payment(
     mocked_process_payment,
     user_api_client,
-    checkout_with_item,
+    checkout_with_voucher,
+    voucher,
     payment_dummy,
     address,
     shipping_method,
@@ -949,9 +950,10 @@ def test_checkout_complete_does_not_delete_checkout_after_unsuccessful_payment(
     def _process_payment(*args, **kwargs):
         raise PaymentError("Oops! Something went wrong.")
 
+    expected_voucher_usage_count = voucher.used
     mocked_process_payment.side_effect = _process_payment
 
-    checkout = checkout_with_item
+    checkout = checkout_with_voucher
     checkout.shipping_address = address
     checkout.shipping_method = shipping_method
     checkout.billing_address = address
@@ -981,6 +983,10 @@ def _process_payment(*args, **kwargs):
     assert payment.transactions.count() == 0
     assert payment.order is None
 
+    # ensure the voucher usage count was not incremented
+    voucher.refresh_from_db(fields=["used"])
+    assert voucher.used == expected_voucher_usage_count
+
     assert Checkout.objects.filter(
         pk=checkout.pk
     ).exists(), "Checkout should not have been deleted"
diff --git a/tests/test_checkout.py b/tests/test_checkout.py
--- a/tests/test_checkout.py
+++ b/tests/test_checkout.py
@@ -261,6 +261,50 @@ def test_view_checkout_shipping_method_without_address(
     assert get_redirect_location(response) == redirect_url
 
 
+@patch("saleor.checkout.utils.send_order_confirmation")
+def test_view_checkout_summary_finalize_with_voucher(
+    mock_send_confirmation,
+    client,
+    shipping_zone,
+    address,
+    request_checkout_with_item,
+    voucher,
+):
+    checkout = request_checkout_with_item
+    expected_voucher_usage_count = voucher.used + 1
+
+    checkout.shipping_address = address
+    checkout.voucher_code = voucher.code
+    checkout.email = "test@example.com"
+    checkout.shipping_method = shipping_zone.shipping_methods.first()
+    checkout.save()
+
+    url = reverse("checkout:summary")
+    data = {"address": "shipping_address"}
+
+    response = client.get(url)
+    assert response.status_code == 200
+    assert response.request["PATH_INFO"] == url
+
+    response = client.post(url, data, follow=True)
+    assert response.status_code == 200
+
+    order = response.context["order"]
+    assert order.user_email == "test@example.com"
+    redirect_url = reverse("order:payment", kwargs={"token": order.token})
+    assert response.request["PATH_INFO"] == redirect_url
+
+    # we expect the user to be anonymous, thus None
+    mock_send_confirmation.delay.assert_called_once_with(order.pk, None)
+
+    # checkout should be deleted after order is created
+    assert checkout.pk is None
+
+    # Ensure the voucher was updated
+    voucher.refresh_from_db(fields=["used"])
+    assert voucher.used == expected_voucher_usage_count
+
+
 @patch("saleor.checkout.utils.send_order_confirmation")
 def test_view_checkout_summary_anonymous_user(
     mock_send_confirmation, client, shipping_zone, address, request_checkout_with_item