Repository: saleor/saleor

Issue:
Voucher with apply_once_per_customer doesn't work with 3DSecure
### What I'm trying to achieve
I'm trying to use voucher with `apply_once_per_customer` set to `True` in the checkout and pay with payment method that requires additional confirmation (3DSecure). However, it fails, because voucher is marked as used during the first call of `checkoutComplete` - and the second call returns an validation error.

### Steps to reproduce the problem
<!-- Adding logs from the console, as well as query/response help us fix the bug faster -->
1. Create voucher with `apply_once_per_customer` set to `True`
2. Add this voucher to the checkout
2. Send `checkoutComplete` that returns `confirmationNeeded`
3. Confirm payment on frontend
4. Send the second `checkoutComplete` mutation

As the result, `checkoutComplete` returns `VOUCHER_NOT_APPLICABLE` error.

### What I expected to happen
The second call of `checkoutComplete` processes payment successfully. 

The hot fix (maybe it's an actual fix) is to add an `else` to the last conditional statement in `checkout_complete` (https://github.com/mirumee/saleor/blob/486a38ff58bab220fef884dd8852112ef5f7b026/saleor/checkout/complete_checkout.py#L612-L627)
```
if not action_required:
        ...
else:
    release_voucher_usage(order_data)
```

### Screenshots and logs
<!-- If applicable, add screenshots to help explain your problem. -->

**System information**
<!-- Provide the version of Saleor or whether you're using it from the `master` branch. If using Saleor Dashboard or Storefront, provide their versions too. -->
Saleor version:
- [x] dev (current master)
- [ ] 3.0
- [x] 2.11
- [ ] 2.10

Operating system:
- [ ] Windows
- [ ] Linux
- [x] MacOS
- [ ] Other



Discussion:
Hello @Kwaidan00  thanks for reporting this issue! I've just added it to our backlog but I also want to point out that the fix will be added in version 3.0 also we advise to switch to this version.

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
@@ -676,6 +676,7 @@ def complete_checkout(
         action_required = txn.action_required
         if action_required:
             action_data = txn.action_required_data
+            release_voucher_usage(order_data)
 
     order = None
     if not action_required:
diff --git a/saleor/discount/utils.py b/saleor/discount/utils.py
--- a/saleor/discount/utils.py
+++ b/saleor/discount/utils.py
@@ -62,10 +62,12 @@ def remove_voucher_usage_by_customer(voucher: "Voucher", customer_email: str) ->
 
 def release_voucher_usage(order_data: dict):
     voucher = order_data.get("voucher")
-    if voucher and voucher.usage_limit:
+    if not voucher:
+        return
+    if voucher.usage_limit:
         decrease_voucher_usage(voucher)
-        if "user_email" in order_data:
-            remove_voucher_usage_by_customer(voucher, order_data["user_email"])
+    if "user_email" in order_data:
+        remove_voucher_usage_by_customer(voucher, order_data["user_email"])
 
 
 def get_product_discount_on_sale(


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
@@ -9,9 +9,14 @@
 from ...core.exceptions import InsufficientStock
 from ...core.notify_events import NotifyEventType
 from ...core.taxes import zero_money, zero_taxed_money
+from ...discount.models import VoucherCustomer
+from ...graphql.checkout.tests.test_checkout_complete import (
+    ACTION_REQUIRED_GATEWAY_RESPONSE,
+)
 from ...order import OrderEvents
 from ...order.models import OrderEvent
 from ...order.notifications import get_default_order_payload
+from ...payment.models import Payment
 from ...plugins.manager import get_plugins_manager
 from ...product.models import ProductTranslation, ProductVariantTranslation
 from ...tests.utils import flush_post_commit_hooks
@@ -970,3 +975,60 @@ def test_complete_checkout_0_total_captured_payment_creates_expected_events(
     assert placement_event.order == order  # check the associated order is valid
     assert placement_event.date  # ensure a date was set
     assert not placement_event.parameters  # should not have any additional parameters
+
+
+@mock.patch("saleor.checkout.complete_checkout._create_order")
+@mock.patch(
+    "saleor.checkout.complete_checkout._process_payment",
+    return_value=ACTION_REQUIRED_GATEWAY_RESPONSE,
+)
+def test_complete_checkout_action_required_voucher_once_per_customer(
+    _,
+    mocked_create_order,
+    voucher,
+    customer_user,
+    checkout,
+    app,
+    payment_txn_to_confirm,
+):
+    # given
+    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()
+
+    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
+    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_create_order.assert_not_called()