Repository: saleor/saleor
Pull request: Add minimal quantity of products for voucher
I want to merge this change because resolve #4316 and fix #4379 



### Screenshots

<!-- If your changes affect the UI, providing "before" and "after" screenshots will
greatly reduce the amount of work needed to review your work. -->

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] Database migration files are up to date.
1. [x] The changes are tested.
1. [ ] The code is documented (docstrings, project documentation).
1. [x] GraphQL schema and type definitions are up to date.
1. [x] Changes are mentioned in the changelog.


Problem statement:
Minimal quantity of products for voucher
I would like to be able to set the minimum number of products before using the voucher.

Using voucher with on going sales on specific product isn't working properly
Percentage specific product vouchers don't give the correct percent if there are items in the sale.

Hints from issue discussion:

Commit message: Add specific product voucher amonut calculation with sale

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cbe96982b4b..f8bd258dbd0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@ All notable, unreleased changes to this project will be documented in this file.
 - Improve vouchers ui - #4362 by @benekex2
 - Fix for random failing tests in test_discount - #4401 by @korycins
 - Fix internal error when users enters an invalid phone number in an address - #4404 by @NyanKiyoshi
+- Add minimal quantity of products for voucher - #4427 by @fowczarek
 - New translations:
   - Icelandic
 
diff --git a/saleor/checkout/utils.py b/saleor/checkout/utils.py
index aabeb91b84c..d1df751841d 100644
--- a/saleor/checkout/utils.py
+++ b/saleor/checkout/utils.py
@@ -101,7 +101,7 @@ def remove_unavailable_variants(checkout):
             add_variant_to_checkout(checkout, line.variant, quantity, replace=True)
 
 
-def get_prices_of_discounted_specific_product(lines, voucher):
+def get_prices_of_discounted_specific_product(lines, voucher, discounts=None):
     """Get prices of variants belonging to the discounted specific products.
 
     Specific products are products, collections and categories.
@@ -131,7 +131,7 @@ def get_prices_of_discounted_specific_product(lines, voucher):
         discounted_lines.extend(list(lines))
 
     for line in discounted_lines:
-        line_total = calculate_checkout_line_total(line, []).gross
+        line_total = calculate_checkout_line_total(line, discounts).gross
         line_unit_price = quantize_price(
             (line_total / line.quantity), line_total.currency
         )
@@ -767,15 +767,16 @@ def _get_shipping_voucher_discount_for_checkout(voucher, checkout, discounts=Non
         voucher,
         calculate_checkout_subtotal(checkout, discounts).gross,
         calculate_checkout_shipping(checkout, discounts).gross,
+        checkout.quantity,
     )
 
 
-def _get_products_voucher_discount(checkout, voucher):
+def _get_products_voucher_discount(checkout, voucher, discounts=None):
     """Calculate products discount value for a voucher, depending on its type.
     """
     prices = None
     if voucher.type == VoucherType.SPECIFIC_PRODUCT:
-        prices = get_prices_of_discounted_specific_product(checkout, voucher)
+        prices = get_prices_of_discounted_specific_product(checkout, voucher, discounts)
     elif voucher.type == VoucherType.PRODUCT:
         prices = get_prices_of_discounted_products(checkout, voucher.products.all())
     elif voucher.type == VoucherType.COLLECTION:
@@ -791,7 +792,12 @@ def _get_products_voucher_discount(checkout, voucher):
             "Voucher not applicable", "This offer is only valid for selected items."
         )
         raise NotApplicable(msg)
-    return get_products_voucher_discount(voucher, prices)
+    return get_products_voucher_discount(
+        voucher,
+        prices,
+        calculate_checkout_subtotal(checkout, discounts).gross,
+        checkout.quantity,
+    )
 
 
 def get_voucher_discount_for_checkout(voucher, checkout, discounts=None):
@@ -801,7 +807,9 @@ def get_voucher_discount_for_checkout(voucher, checkout, discounts=None):
     """
     if voucher.type == VoucherType.ENTIRE_ORDER:
         return get_value_voucher_discount(
-            voucher, calculate_checkout_subtotal(checkout, discounts).gross
+            voucher,
+            calculate_checkout_subtotal(checkout, discounts).gross,
+            checkout.quantity,
         )
     if voucher.type == VoucherType.SHIPPING:
         return _get_shipping_voucher_discount_for_checkout(voucher, checkout, discounts)
@@ -811,7 +819,7 @@ def get_voucher_discount_for_checkout(voucher, checkout, discounts=None):
         VoucherType.CATEGORY,
         VoucherType.SPECIFIC_PRODUCT,
     ):
-        return _get_products_voucher_discount(checkout, voucher)
+        return _get_products_voucher_discount(checkout, voucher, discounts)
     raise NotImplementedError("Unknown discount type")
 
 
diff --git a/saleor/core/utils/random_data.py b/saleor/core/utils/random_data.py
index 14a9bee2dbc..a80754e10a0 100644
--- a/saleor/core/utils/random_data.py
+++ b/saleor/core/utils/random_data.py
@@ -1,4 +1,3 @@
-import datetime
 import itertools
 import json
 import os
@@ -11,6 +10,7 @@
 from django.conf import settings
 from django.contrib.sites.models import Site
 from django.core.files import File
+from django.utils import timezone
 from faker import Factory
 from faker.providers import BaseProvider
 from measurement.measures import Weight
@@ -460,7 +460,7 @@ def create_users(how_many=10):
 
 
 def create_orders(how_many=10):
-    discounts = fetch_discounts(datetime.date.today())
+    discounts = fetch_discounts(timezone.now())
     for _ in range(how_many):
         order = create_fake_order(discounts)
         yield "Order: %s" % (order,)
diff --git a/saleor/dashboard/discount/forms.py b/saleor/dashboard/discount/forms.py
index 053ce471900..7509c9b9106 100644
--- a/saleor/dashboard/discount/forms.py
+++ b/saleor/dashboard/discount/forms.py
@@ -93,6 +93,9 @@ class Meta:
             "name": pgettext_lazy("Item name", "Name"),
             "code": pgettext_lazy("Coupon code", "Code"),
             "usage_limit": pgettext_lazy("Usage limit", "Usage limit"),
+            "min_quantity_of_products": pgettext_lazy(
+                "Voucher: discount with", "Minimal quantity of products"
+            ),
             "start_date": pgettext_lazy("Voucher date restrictions", "Start date"),
             "end_date": pgettext_lazy("Voucher date restrictions", "End date"),
             "discount_value_type": pgettext_lazy(
@@ -125,7 +128,7 @@ class ShippingVoucherForm(forms.ModelForm):
 
     class Meta:
         model = Voucher
-        fields = ["countries", "min_amount_spent"]
+        fields = ["countries", "min_amount_spent", "min_quantity_of_products"]
 
 
 class EntireOrderVoucherForm(forms.ModelForm):
@@ -133,7 +136,7 @@ class EntireOrderVoucherForm(forms.ModelForm):
 
     class Meta:
         model = Voucher
-        fields = ["min_amount_spent"]
+        fields = ["min_amount_spent", "min_quantity_of_products"]
 
     def save(self, commit=True):
         self.instance.category = None
@@ -184,6 +187,7 @@ class Meta:
             "categories",
             "apply_once_per_order",
             "min_amount_spent",
+            "min_quantity_of_products",
         ]
         labels = {
             "collections": pgettext_lazy(
@@ -209,7 +213,12 @@ class ProductVoucherForm(CommonVoucherForm):
 
     class Meta:
         model = Voucher
-        fields = ["products", "apply_once_per_order", "min_amount_spent"]
+        fields = [
+            "products",
+            "apply_once_per_order",
+            "min_amount_spent",
+            "min_quantity_of_products",
+        ]
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
@@ -220,7 +229,12 @@ def __init__(self, *args, **kwargs):
 class CollectionVoucherForm(CommonVoucherForm):
     class Meta:
         model = Voucher
-        fields = ["collections", "apply_once_per_order", "min_amount_spent"]
+        fields = [
+            "collections",
+            "apply_once_per_order",
+            "min_amount_spent",
+            "min_quantity_of_products",
+        ]
         labels = {"collections": pgettext_lazy("Collections", "Collections")}
 
     def __init__(self, *args, **kwargs):
@@ -237,4 +251,9 @@ class CategoryVoucherForm(CommonVoucherForm):
 
     class Meta:
         model = Voucher
-        fields = ["categories", "apply_once_per_order", "min_amount_spent"]
+        fields = [
+            "categories",
+            "apply_once_per_order",
+            "min_amount_spent",
+            "min_quantity_of_products",
+        ]
diff --git a/saleor/dashboard/order/utils.py b/saleor/dashboard/order/utils.py
index 05e3999bf92..e2c8ed0915a 100644
--- a/saleor/dashboard/order/utils.py
+++ b/saleor/dashboard/order/utils.py
@@ -140,7 +140,9 @@ def get_products_voucher_discount_for_order(order, voucher):
             "Voucher not applicable", "This offer is only valid for selected items."
         )
         raise NotApplicable(msg)
-    return get_products_voucher_discount(voucher, prices)
+    return get_products_voucher_discount(
+        voucher, prices, order.get_subtotal().gross, order.get_total_quantity()
+    )
 
 
 def get_voucher_discount_for_order(order):
@@ -152,10 +154,15 @@ def get_voucher_discount_for_order(order):
         return zero_money()
     subtotal = order.get_subtotal()
     if order.voucher.type == VoucherType.ENTIRE_ORDER:
-        return get_value_voucher_discount(order.voucher, subtotal.gross)
+        return get_value_voucher_discount(
+            order.voucher, subtotal.gross, order.get_total_quantity()
+        )
     if order.voucher.type == VoucherType.SHIPPING:
         return get_shipping_voucher_discount(
-            order.voucher, subtotal.gross, order.shipping_price
+            order.voucher,
+            subtotal.gross,
+            order.shipping_price,
+            order.get_total_quantity(),
         )
     if order.voucher.type in (
         VoucherType.PRODUCT,
diff --git a/saleor/discount/migrations/0015_voucher_min_quantity_of_products.py b/saleor/discount/migrations/0015_voucher_min_quantity_of_products.py
new file mode 100644
index 00000000000..b97e7829822
--- /dev/null
+++ b/saleor/discount/migrations/0015_voucher_min_quantity_of_products.py
@@ -0,0 +1,16 @@
+# Generated by Django 2.2.3 on 2019-07-04 08:40
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [("discount", "0014_auto_20190701_0402")]
+
+    operations = [
+        migrations.AddField(
+            model_name="voucher",
+            name="min_quantity_of_products",
+            field=models.PositiveIntegerField(blank=True, null=True),
+        )
+    ]
diff --git a/saleor/discount/models.py b/saleor/discount/models.py
index 0a43e512623..50831d152ad 100644
--- a/saleor/discount/models.py
+++ b/saleor/discount/models.py
@@ -19,13 +19,15 @@ class NotApplicable(ValueError):
     """Exception raised when a discount is not applicable to a checkout.
 
     The error is raised if the order value is below the minimum required
-    price.
+    price or the order quantity is below the minimum quantity of items.
     Minimum price will be available as the `min_amount_spent` attribute.
+    Minimum quantity will be available as the `min_quantity_of_products` attribute.
     """
 
-    def __init__(self, msg, min_amount_spent=None):
+    def __init__(self, msg, min_amount_spent=None, min_quantity_of_products=None):
         super().__init__(msg)
         self.min_amount_spent = min_amount_spent
+        self.min_quantity_of_products = min_quantity_of_products
 
 
 class VoucherQueryset(models.QuerySet):
@@ -73,6 +75,7 @@ class Voucher(models.Model):
         null=True,
         blank=True,
     )
+    min_quantity_of_products = models.PositiveIntegerField(null=True, blank=True)
     products = models.ManyToManyField("product.Product", blank=True)
     collections = models.ManyToManyField("product.Collection", blank=True)
     categories = models.ManyToManyField("product.Category", blank=True)
@@ -147,6 +150,21 @@ def validate_min_amount_spent(self, value):
                 min_amount_spent=min_amount_spent,
             )
 
+    def validate_min_quantity_of_products(self, quantity):
+        min_quantity_of_products = self.min_quantity_of_products
+        if min_quantity_of_products and min_quantity_of_products > quantity:
+            msg = pgettext(
+                "Voucher not applicable",
+                (
+                    "This offer is only valid for orders with a minimum of "
+                    "%(min_quantity_of_products)d quantity."
+                ),
+            )
+            raise NotApplicable(
+                msg % {"min_quantity_of_products": min_quantity_of_products},
+                min_quantity_of_products=min_quantity_of_products,
+            )
+
 
 class SaleQueryset(models.QuerySet):
     def active(self, date):
diff --git a/saleor/discount/utils.py b/saleor/discount/utils.py
index 800c9caab3a..3b1f303ef86 100644
--- a/saleor/discount/utils.py
+++ b/saleor/discount/utils.py
@@ -61,20 +61,24 @@ def calculate_discounted_price(product, price, discounts: Iterable[DiscountInfo]
     return price
 
 
-def get_value_voucher_discount(voucher, total_price):
+def get_value_voucher_discount(voucher, total_price, quantity):
     """Calculate discount value for a voucher of value type."""
     voucher.validate_min_amount_spent(total_price)
+    voucher.validate_min_quantity_of_products(quantity)
     return voucher.get_discount_amount_for(total_price)
 
 
-def get_shipping_voucher_discount(voucher, total_price, shipping_price):
+def get_shipping_voucher_discount(voucher, total_price, shipping_price, quantity):
     """Calculate discount value for a voucher of shipping type."""
     voucher.validate_min_amount_spent(total_price)
+    voucher.validate_min_quantity_of_products(quantity)
     return voucher.get_discount_amount_for(shipping_price)
 
 
-def get_products_voucher_discount(voucher, prices):
+def get_products_voucher_discount(voucher, prices, total_price, quantity):
     """Calculate discount value for a voucher of product or category type."""
+    voucher.validate_min_amount_spent(total_price)
+    voucher.validate_min_quantity_of_products(quantity)
     if voucher.apply_once_per_order:
         return voucher.get_discount_amount_for(min(prices))
     discounts = (voucher.get_discount_amount_for(price) for price in prices)
diff --git a/saleor/graphql/discount/mutations.py b/saleor/graphql/discount/mutations.py
index 77f69b85b47..286d55cc8c3 100644
--- a/saleor/graphql/discount/mutations.py
+++ b/saleor/graphql/discount/mutations.py
@@ -101,12 +101,15 @@ class VoucherInput(graphene.InputObjectType):
     min_amount_spent = Decimal(
         description="Min purchase amount required to apply the voucher."
     )
+    min_quantity_of_products = graphene.Int(
+        description="Minimal quantity of products required to apply the voucher."
+    )
     countries = graphene.List(
         graphene.String,
-        description="Country codes that can be used with the shipping voucher",
+        description="Country codes that can be used with the shipping voucher.",
     )
     apply_once_per_order = graphene.Boolean(
-        description="Voucher should be applied to the cheapest item or entire order"
+        description="Voucher should be applied to the cheapest item or entire order."
     )
     usage_limit = graphene.Int(
         description="Limit number of times this voucher can be used in total"
diff --git a/saleor/graphql/discount/types.py b/saleor/graphql/discount/types.py
index fe541b2c3df..8d0c3e8bc34 100644
--- a/saleor/graphql/discount/types.py
+++ b/saleor/graphql/discount/types.py
@@ -116,6 +116,7 @@ class Meta:
             "end_date",
             "id",
             "min_amount_spent",
+            "min_quantity_of_products",
             "name",
             "start_date",
             "type",
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 3ca772d337f..5da99f3bacd 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -3131,6 +3131,7 @@ type Voucher implements Node {
   discountValueType: DiscountValueTypeEnum!
   discountValue: Float!
   minAmountSpent: Money
+  minQuantityOfProducts: Int
   categories(before: String, after: String, first: Int, last: Int): CategoryCountableConnection
   collections(before: String, after: String, first: Int, last: Int): CollectionCountableConnection
   products(before: String, after: String, first: Int, last: Int): ProductCountableConnection
@@ -3195,6 +3196,7 @@ input VoucherInput {
   collections: [ID]
   categories: [ID]
   minAmountSpent: Decimal
+  minQuantityOfProducts: Int
   countries: [String]
   applyOncePerOrder: Boolean
   usageLimit: Int
diff --git a/tests/api/test_checkout_promo_codes.py b/tests/api/test_checkout_promo_codes.py
index cc9789fc76c..27197861a29 100644
--- a/tests/api/test_checkout_promo_codes.py
+++ b/tests/api/test_checkout_promo_codes.py
@@ -314,10 +314,26 @@ def test_checkout_add_voucher_code_checkout_with_sale(
     variables = {"checkoutId": checkout_id, "promoCode": voucher_percentage.code}
     data = _mutate_checkout_add_promo_code(api_client, variables)
 
+    checkout_with_item.refresh_from_db()
     assert not data["errors"]
-    assert data["checkout"]["id"] == checkout_id
-    assert data["checkout"]["voucherCode"] == voucher_percentage.code
-    assert data["checkout"]["totalPrice"]["gross"]["amount"] == 13.5
+    assert checkout_with_item.voucher_code == voucher_percentage.code
+    assert checkout_with_item.discount_amount.amount == 1.5
+
+
+def test_checkout_add_specific_product_voucher_code_checkout_with_sale(
+    api_client, checkout_with_item, voucher_specific_product_type, discount_info
+):
+    voucher = voucher_specific_product_type
+    checkout = checkout_with_item
+    assert checkout.get_subtotal() > checkout.get_subtotal(discounts=[discount_info])
+    checkout_id = graphene.Node.to_global_id("Checkout", checkout.pk)
+    variables = {"checkoutId": checkout_id, "promoCode": voucher.code}
+    data = _mutate_checkout_add_promo_code(api_client, variables)
+
+    checkout.refresh_from_db()
+    assert not data["errors"]
+    assert checkout.voucher_code == voucher.code
+    assert checkout.discount_amount.amount == 1.5
 
 
 def test_checkout_add_voucher_code_not_applicable_voucher(
diff --git a/tests/api/test_discount.py b/tests/api/test_discount.py
index 2cfd8199a6b..ad4ff9ea173 100644
--- a/tests/api/test_discount.py
+++ b/tests/api/test_discount.py
@@ -140,15 +140,15 @@ def test_sale_query(staff_api_client, sale, permission_manage_discounts):
 CREATE_VOUCHER_MUTATION = """
 mutation  voucherCreate(
     $type: VoucherTypeEnum, $name: String, $code: String,
-    $discountValueType: DiscountValueTypeEnum,
-    $discountValue: Decimal, $minAmountSpent: Decimal,
-    $startDate: DateTime, $endDate: DateTime, $applyOncePerOrder: Boolean,
-    $usageLimit: Int) {
+    $discountValueType: DiscountValueTypeEnum, $usageLimit: Int,
+    $discountValue: Decimal, $minAmountSpent: Decimal, $minQuantityOfProducts: Int,
+    $startDate: DateTime, $endDate: DateTime, $applyOncePerOrder: Boolean) {
         voucherCreate(input: {
                 name: $name, type: $type, code: $code,
                 discountValueType: $discountValueType,
                 discountValue: $discountValue,
                 minAmountSpent: $minAmountSpent,
+                minQuantityOfProducts: $minQuantityOfProducts,
                 startDate: $startDate, endDate: $endDate,
                 applyOncePerOrder: $applyOncePerOrder, usageLimit: $usageLimit}) {
             errors {
@@ -160,6 +160,7 @@ def test_sale_query(staff_api_client, sale, permission_manage_discounts):
                 minAmountSpent {
                     amount
                 }
+                minQuantityOfProducts
                 name
                 code
                 discountValueType
@@ -183,6 +184,7 @@ def test_create_voucher(staff_api_client, permission_manage_discounts):
         "discountValueType": DiscountValueTypeEnum.FIXED.name,
         "discountValue": 10.12,
         "minAmountSpent": 1.12,
+        "minQuantityOfProducts": 10,
         "startDate": start_date.isoformat(),
         "endDate": end_date.isoformat(),
         "applyOncePerOrder": True,
@@ -317,10 +319,11 @@ def test_update_voucher(staff_api_client, voucher, permission_manage_discounts):
     query = """
     mutation  voucherUpdate($code: String,
         $discountValueType: DiscountValueTypeEnum, $id: ID!,
-        $applyOncePerOrder: Boolean) {
+        $applyOncePerOrder: Boolean, $minQuantityOfProducts: Int) {
             voucherUpdate(id: $id, input: {
                 code: $code, discountValueType: $discountValueType,
-                applyOncePerOrder: $applyOncePerOrder
+                applyOncePerOrder: $applyOncePerOrder,
+                minQuantityOfProducts: $minQuantityOfProducts
                 }) {
                 errors {
                     field
@@ -330,6 +333,7 @@ def test_update_voucher(staff_api_client, voucher, permission_manage_discounts):
                     code
                     discountValueType
                     applyOncePerOrder
+                    minQuantityOfProducts
                 }
             }
         }
@@ -344,6 +348,7 @@ def test_update_voucher(staff_api_client, voucher, permission_manage_discounts):
         "code": "testcode123",
         "discountValueType": DiscountValueTypeEnum.PERCENTAGE.name,
         "applyOncePerOrder": apply_once_per_order,
+        "minQuantityOfProducts": 10,
     }
 
     response = staff_api_client.post_graphql(
@@ -354,6 +359,7 @@ def test_update_voucher(staff_api_client, voucher, permission_manage_discounts):
     assert data["code"] == "testcode123"
     assert data["discountValueType"] == DiscountValueType.PERCENTAGE.upper()
     assert data["applyOncePerOrder"] == apply_once_per_order
+    assert data["minQuantityOfProducts"] == 10
 
 
 def test_voucher_delete_mutation(
diff --git a/tests/conftest.py b/tests/conftest.py
index 4c99590fbf7..4e60b2c7dd8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -667,13 +667,6 @@ def voucher(db):  # pylint: disable=W0613
     return Voucher.objects.create(code="mirumee", discount_value=20)
 
 
-@pytest.fixture
-def voucher_specific_product_type(db):
-    return Voucher.objects.create(
-        code="mirumee", discount_value=10, type=VoucherType.SPECIFIC_PRODUCT
-    )
-
-
 @pytest.fixture
 def voucher_percentage(db):
     return Voucher.objects.create(
@@ -683,6 +676,13 @@ def voucher_percentage(db):
     )
 
 
+@pytest.fixture
+def voucher_specific_product_type(voucher_percentage):
+    voucher_percentage.type = VoucherType.SPECIFIC_PRODUCT
+    voucher_percentage.save()
+    return voucher_percentage
+
+
 @pytest.fixture
 def voucher_with_high_min_amount_spent():
     return Voucher.objects.create(
diff --git a/tests/dashboard/test_discounts.py b/tests/dashboard/test_discounts.py
index 783fefed8e7..7df7c4ffe3f 100644
--- a/tests/dashboard/test_discounts.py
+++ b/tests/dashboard/test_discounts.py
@@ -153,18 +153,40 @@ def test_shipping_voucher_order_discount(
     assert discount == Money(expected_value, "USD")
 
 
-def test_shipping_voucher_checkout_discount_not_applicable_returns_zero():
+@pytest.mark.parametrize(
+    "total, total_quantity, min_amount_spent, min_quantity_of_products, voucher_type",
+    [
+        (99, 10, 100, 10, VoucherType.SHIPPING),
+        (100, 9, 100, 10, VoucherType.SHIPPING),
+        (99, 9, 100, 10, VoucherType.SHIPPING),
+        (99, 10, 100, 10, VoucherType.ENTIRE_ORDER),
+        (100, 9, 100, 10, VoucherType.ENTIRE_ORDER),
+        (99, 9, 100, 10, VoucherType.ENTIRE_ORDER),
+        (99, 10, 100, 10, VoucherType.SPECIFIC_PRODUCT),
+        (100, 9, 100, 10, VoucherType.SPECIFIC_PRODUCT),
+        (99, 9, 100, 10, VoucherType.SPECIFIC_PRODUCT),
+    ],
+)
+def test_shipping_voucher_checkout_discount_not_applicable_returns_zero(
+    total, total_quantity, min_amount_spent, min_quantity_of_products, voucher_type
+):
     voucher = Voucher(
         code="unique",
-        type=VoucherType.SHIPPING,
+        type=voucher_type,
         discount_value_type=DiscountValueType.FIXED,
         discount_value=10,
-        min_amount_spent=Money(20, "USD"),
+        min_amount_spent=(
+            Money(min_amount_spent, "USD") if min_amount_spent is not None else None
+        ),
+        min_quantity_of_products=min_quantity_of_products,
     )
-    price = Money(10, "USD")
+    price = Money(total, "USD")
     price = TaxedMoney(net=price, gross=price)
     order = Mock(
-        get_subtotal=Mock(return_value=price), shipping_price=price, voucher=voucher
+        get_subtotal=Mock(return_value=price),
+        get_total_quantity=Mock(return_value=total_quantity),
+        shipping_price=price,
+        voucher=voucher,
     )
     with pytest.raises(NotApplicable):
         get_voucher_discount_for_order(order)
diff --git a/tests/test_checkout.py b/tests/test_checkout.py
index 179c12eef70..595e3c861bc 100644
--- a/tests/test_checkout.py
+++ b/tests/test_checkout.py
@@ -848,6 +848,42 @@ def test_get_discount_for_checkout_value_voucher(
     assert discount == Money(discount_amount, "USD")
 
 
+@pytest.mark.parametrize(
+    "total, total_quantity, discount_value, discount_type, min_amount_spent, "
+    "min_quantity_of_products",
+    [
+        ("99", 9, 10, DiscountValueType.FIXED, None, 10),
+        ("99", 9, 10, DiscountValueType.FIXED, 100, None),
+        ("99", 10, 10, DiscountValueType.PERCENTAGE, 100, 10),
+        ("100", 9, 10, DiscountValueType.PERCENTAGE, 100, 10),
+        ("99", 9, 10, DiscountValueType.PERCENTAGE, 100, 10),
+    ],
+)
+def test_get_discount_for_checkout_value_voucher_not_applicable(
+    total,
+    total_quantity,
+    discount_value,
+    discount_type,
+    min_amount_spent,
+    min_quantity_of_products,
+):
+    voucher = Voucher(
+        code="unique",
+        type=VoucherType.ENTIRE_ORDER,
+        discount_value_type=discount_type,
+        discount_value=discount_value,
+        min_amount_spent=(
+            Money(min_amount_spent, "USD") if min_amount_spent is not None else None
+        ),
+        min_quantity_of_products=min_quantity_of_products,
+    )
+    checkout = Mock(
+        get_subtotal=Mock(return_value=Money(total, "USD")), quantity=total_quantity
+    )
+    with pytest.raises(NotApplicable):
+        get_voucher_discount_for_checkout(voucher, checkout)
+
+
 @pytest.mark.parametrize(
     "discount_value, discount_type, apply_once_per_order, discount_amount",
     [
@@ -858,7 +894,7 @@ def test_get_discount_for_checkout_value_voucher(
         (10, DiscountValueType.PERCENTAGE, False, 5),
     ],
 )
-def test_get_discount_for_checkout_apply_once_per_order(
+def test_get_discount_for_checkout_specific_products_voucher(
     checkout_with_items,
     product_list,
     discount_value,
@@ -868,7 +904,7 @@ def test_get_discount_for_checkout_apply_once_per_order(
 ):
     voucher = Voucher.objects.create(
         code="unique",
-        type=VoucherType.PRODUCT,
+        type=VoucherType.SPECIFIC_PRODUCT,
         discount_value_type=discount_type,
         discount_value=discount_value,
         apply_once_per_order=apply_once_per_order,
@@ -879,18 +915,46 @@ def test_get_discount_for_checkout_apply_once_per_order(
     assert discount == Money(discount_amount, "USD")
 
 
-def test_get_discount_for_checkout_value_voucher_not_applicable():
+@pytest.mark.parametrize(
+    "total, total_quantity, discount_value, discount_type, min_amount_spent,"
+    "min_quantity_of_products",
+    [
+        ("99", 9, 10, DiscountValueType.FIXED, None, 10),
+        ("99", 9, 10, DiscountValueType.FIXED, 100, None),
+        ("99", 10, 10, DiscountValueType.PERCENTAGE, 100, 10),
+        ("100", 9, 10, DiscountValueType.PERCENTAGE, 100, 10),
+        ("99", 9, 10, DiscountValueType.PERCENTAGE, 100, 10),
+    ],
+)
+def test_get_discount_for_checkout_specific_products_voucher_not_applicable(
+    monkeypatch,
+    total,
+    total_quantity,
+    discount_value,
+    discount_type,
+    min_amount_spent,
+    min_quantity_of_products,
+):
+    discounts = []
+    monkeypatch.setattr(
+        "saleor.checkout.utils.get_prices_of_discounted_specific_product",
+        lambda checkout, discounts, product: [],
+    )
     voucher = Voucher(
         code="unique",
-        type=VoucherType.ENTIRE_ORDER,
-        discount_value_type=DiscountValueType.FIXED,
-        discount_value=10,
-        min_amount_spent=Money(100, "USD"),
+        type=VoucherType.SPECIFIC_PRODUCT,
+        discount_value_type=discount_type,
+        discount_value=discount_value,
+        min_amount_spent=(
+            Money(min_amount_spent, "USD") if min_amount_spent is not None else None
+        ),
+        min_quantity_of_products=min_quantity_of_products,
     )
-    checkout = Mock(get_subtotal=Mock(return_value=Money(10, "USD")))
-    with pytest.raises(NotApplicable) as e:
-        get_voucher_discount_for_checkout(voucher, checkout)
-    assert e.value.min_amount_spent == Money(100, "USD")
+    checkout = Mock(
+        get_subtotal=Mock(return_value=Money(total, "USD")), quantity=total_quantity
+    )
+    with pytest.raises(NotApplicable):
+        get_voucher_discount_for_checkout(voucher, checkout, discounts)
 
 
 @pytest.mark.parametrize(
@@ -977,7 +1041,8 @@ def test_get_discount_for_checkout_shipping_voucher_limited_countries():
 
 @pytest.mark.parametrize(
     "is_shipping_required, shipping_method, discount_value, discount_type,"
-    "countries, min_amount_spent, subtotal, error_msg",
+    "countries, min_amount_spent, min_quantity_of_products, subtotal, total_quantity,"
+    "error_msg",
     [
         (
             True,
@@ -986,7 +1051,9 @@ def test_get_discount_for_checkout_shipping_voucher_limited_countries():
             DiscountValueType.FIXED,
             ["US"],
             None,
+            None,
             Money(10, "USD"),
+            10,
             "This offer is not valid in your country.",
         ),
         (
@@ -996,7 +1063,9 @@ def test_get_discount_for_checkout_shipping_voucher_limited_countries():
             DiscountValueType.FIXED,
             [],
             None,
+            None,
             Money(10, "USD"),
+            10,
             "Please select a shipping method first.",
         ),
         (
@@ -1006,7 +1075,9 @@ def test_get_discount_for_checkout_shipping_voucher_limited_countries():
             DiscountValueType.FIXED,
             [],
             None,
+            None,
             Money(10, "USD"),
+            10,
             "Your order does not require shipping.",
         ),
         (
@@ -1016,7 +1087,33 @@ def test_get_discount_for_checkout_shipping_voucher_limited_countries():
             DiscountValueType.FIXED,
             [],
             5,
+            None,
+            Money(2, "USD"),
+            10,
+            "This offer is only valid for orders over $5.00.",
+        ),
+        (
+            True,
+            Mock(price=Money(10, "USD")),
+            10,
+            DiscountValueType.FIXED,
+            [],
+            5,
+            10,
+            Money(5, "USD"),
+            9,
+            "This offer is only valid for orders with a minimum of 10 quantity.",
+        ),
+        (
+            True,
+            Mock(price=Money(10, "USD")),
+            10,
+            DiscountValueType.FIXED,
+            [],
+            5,
+            10,
             Money(2, "USD"),
+            9,
             "This offer is only valid for orders over $5.00.",
         ),
     ],
@@ -1028,7 +1125,9 @@ def test_get_discount_for_checkout_shipping_voucher_not_applicable(
     discount_type,
     countries,
     min_amount_spent,
+    min_quantity_of_products,
     subtotal,
+    total_quantity,
     error_msg,
 ):
     checkout = Mock(
@@ -1036,6 +1135,7 @@ def test_get_discount_for_checkout_shipping_voucher_not_applicable(
         is_shipping_required=Mock(return_value=is_shipping_required),
         shipping_method=shipping_method,
         get_shipping_price=Mock(return_value=Money(10, "USD")),
+        quantity=total_quantity,
     )
     voucher = Voucher(
         code="unique",
@@ -1045,6 +1145,7 @@ def test_get_discount_for_checkout_shipping_voucher_not_applicable(
         min_amount_spent=(
             Money(min_amount_spent, "USD") if min_amount_spent is not None else None
         ),
+        min_quantity_of_products=min_quantity_of_products,
         countries=countries,
     )
     with pytest.raises(NotApplicable) as e:
diff --git a/tests/test_discounts.py b/tests/test_discounts.py
index 05fe5ced51d..5973c42702c 100644
--- a/tests/test_discounts.py
+++ b/tests/test_discounts.py
@@ -179,16 +179,23 @@ def test_decrease_voucher_usage():
 
 
 @pytest.mark.parametrize(
-    "total, min_amount_spent, discount_value, discount_value_type, expected_value",
+    "total, min_amount_spent, total_quantity, min_quantity_of_products, "
+    "discount_value, discount_value_type, expected_value",
     [
-        (20, 15, 50, DiscountValueType.PERCENTAGE, 10),
-        (20, None, 50, DiscountValueType.PERCENTAGE, 10),
-        (20, 15, 5, DiscountValueType.FIXED, 5),
-        (20, None, 5, DiscountValueType.FIXED, 5),
+        (20, 20, 2, 2, 50, DiscountValueType.PERCENTAGE, 10),
+        (20, None, 2, None, 50, DiscountValueType.PERCENTAGE, 10),
+        (20, 20, 2, 2, 5, DiscountValueType.FIXED, 5),
+        (20, None, 2, None, 5, DiscountValueType.FIXED, 5),
     ],
 )
 def test_get_value_voucher_discount(
-    total, min_amount_spent, discount_value, discount_value_type, expected_value
+    total,
+    min_amount_spent,
+    total_quantity,
+    min_quantity_of_products,
+    discount_value,
+    discount_value_type,
+    expected_value,
 ):
     voucher = Voucher(
         code="unique",
@@ -196,26 +203,60 @@ def test_get_value_voucher_discount(
         discount_value_type=discount_value_type,
         discount_value=discount_value,
         min_amount_spent=get_min_amount_spent(min_amount_spent),
+        min_quantity_of_products=min_quantity_of_products,
     )
     voucher.save()
     total_price = Money(total, "USD")
-    discount = get_value_voucher_discount(voucher, total_price)
+    discount = get_value_voucher_discount(voucher, total_price, total_quantity)
     assert discount == Money(expected_value, "USD")
 
 
 @pytest.mark.parametrize(
-    "total, min_amount_spent, shipping_price, discount_value, "
-    "discount_value_type, expected_value",
+    "total, min_amount_spent, total_quantity, min_quantity_of_products, "
+    "discount_value, discount_value_type",
+    [
+        (20, 50, 2, 10, 50, DiscountValueType.PERCENTAGE),
+        (20, 50, 2, None, 50, DiscountValueType.PERCENTAGE),
+        (20, None, 2, 10, 50, DiscountValueType.FIXED),
+    ],
+)
+def test_get_value_voucher_discount_not_applicable(
+    total,
+    min_amount_spent,
+    total_quantity,
+    min_quantity_of_products,
+    discount_value,
+    discount_value_type,
+):
+    voucher = Voucher(
+        code="unique",
+        type=VoucherType.ENTIRE_ORDER,
+        discount_value_type=discount_value_type,
+        discount_value=discount_value,
+        min_amount_spent=get_min_amount_spent(min_amount_spent),
+        min_quantity_of_products=min_quantity_of_products,
+    )
+    voucher.save()
+    total_price = Money(total, "USD")
+    with pytest.raises(NotApplicable):
+        get_value_voucher_discount(voucher, total_price, total_quantity)
+
+
+@pytest.mark.parametrize(
+    "total, min_amount_spent, total_quantity, min_quantity_of_products, "
+    "shipping_price, discount_value, discount_value_type, expected_value",
     [
-        (20, 15, 10, 50, DiscountValueType.PERCENTAGE, 5),
-        (20, None, 10, 50, DiscountValueType.PERCENTAGE, 5),
-        (20, 15, 10, 5, DiscountValueType.FIXED, 5),
-        (20, None, 10, 5, DiscountValueType.FIXED, 5),
+        (20, 20, 2, 2, 10, 50, DiscountValueType.PERCENTAGE, 5),
+        (20, None, 2, None, 10, 50, DiscountValueType.PERCENTAGE, 5),
+        (20, 20, 2, 2, 10, 5, DiscountValueType.FIXED, 5),
+        (20, None, 2, None, 10, 5, DiscountValueType.FIXED, 5),
     ],
 )
 def test_get_shipping_voucher_discount(
     total,
     min_amount_spent,
+    total_quantity,
+    min_quantity_of_products,
     shipping_price,
     discount_value,
     discount_value_type,
@@ -227,24 +268,116 @@ def test_get_shipping_voucher_discount(
         discount_value_type=discount_value_type,
         discount_value=discount_value,
         min_amount_spent=get_min_amount_spent(min_amount_spent),
+        min_quantity_of_products=min_quantity_of_products,
     )
     voucher.save()
     total = Money(total, "USD")
     shipping_price = Money(shipping_price, "USD")
-    discount = get_shipping_voucher_discount(voucher, total, shipping_price)
+    discount = get_shipping_voucher_discount(
+        voucher, total, shipping_price, total_quantity
+    )
     assert discount == Money(expected_value, "USD")
 
 
 @pytest.mark.parametrize(
-    "prices, discount_value_type, discount_value, voucher_type, expected_value",
+    "total, min_amount_spent, total_quantity, min_quantity_of_products, "
+    "shipping_price, discount_value, discount_value_type",
+    [
+        (20, 50, 2, 10, 10, 50, DiscountValueType.PERCENTAGE),
+        (20, 50, 2, None, 10, 50, DiscountValueType.PERCENTAGE),
+        (20, None, 2, 10, 10, 5, DiscountValueType.FIXED),
+    ],
+)
+def test_get_shipping_voucher_discount_not_applicable(
+    total,
+    min_amount_spent,
+    total_quantity,
+    min_quantity_of_products,
+    shipping_price,
+    discount_value,
+    discount_value_type,
+):
+    voucher = Voucher(
+        code="unique",
+        type=VoucherType.ENTIRE_ORDER,
+        discount_value_type=discount_value_type,
+        discount_value=discount_value,
+        min_amount_spent=get_min_amount_spent(min_amount_spent),
+        min_quantity_of_products=min_quantity_of_products,
+    )
+    voucher.save()
+    total = Money(total, "USD")
+    shipping_price = Money(shipping_price, "USD")
+    with pytest.raises(NotApplicable):
+        get_shipping_voucher_discount(voucher, total, shipping_price, total_quantity)
+
+
+@pytest.mark.parametrize(
+    "prices, total, min_amount_spent, total_quantity, min_quantity_of_products, "
+    "discount_value_type, discount_value, voucher_type, apply_once_per_order, "
+    "expected_value",
     [  # noqa
-        ([5, 10, 15], DiscountValueType.PERCENTAGE, 10, VoucherType.PRODUCT, 3),
-        ([5, 10, 15], DiscountValueType.FIXED, 2, VoucherType.PRODUCT, 6),
-        ([5, 10, 15], DiscountValueType.FIXED, 2, VoucherType.COLLECTION, 6),
+        (
+            [5, 10, 15],
+            20,
+            20,
+            4,
+            4,
+            DiscountValueType.PERCENTAGE,
+            10,
+            VoucherType.PRODUCT,
+            False,
+            3,
+        ),
+        (
+            [5, 10, 15],
+            20,
+            None,
+            4,
+            None,
+            DiscountValueType.PERCENTAGE,
+            20,
+            VoucherType.PRODUCT,
+            True,
+            1,
+        ),
+        (
+            [5, 10, 15],
+            20,
+            20,
+            4,
+            4,
+            DiscountValueType.FIXED,
+            2,
+            VoucherType.SPECIFIC_PRODUCT,
+            False,
+            6,
+        ),
+        (
+            [5, 10, 15],
+            20,
+            None,
+            4,
+            None,
+            DiscountValueType.FIXED,
+            2,
+            VoucherType.COLLECTION,
+            True,
+            2,
+        ),
     ],
 )
 def test_get_voucher_discount_all_products(
-    prices, discount_value_type, discount_value, voucher_type, expected_value
+    prices,
+    total,
+    min_amount_spent,
+    total_quantity,
+    min_quantity_of_products,
+    discount_value_type,
+    discount_value,
+    voucher_type,
+    apply_once_per_order,
+    expected_value,
 ):
     prices = [Money(price, "USD") for price in prices]
     voucher = Voucher(
@@ -252,12 +385,93 @@ def test_get_voucher_discount_all_products(
         type=voucher_type,
         discount_value_type=discount_value_type,
         discount_value=discount_value,
+        apply_once_per_order=apply_once_per_order,
+        min_amount_spent=get_min_amount_spent(min_amount_spent),
+        min_quantity_of_products=min_quantity_of_products,
     )
     voucher.save()
-    discount = get_products_voucher_discount(voucher, prices)
+    total = Money(total, "USD")
+    discount = get_products_voucher_discount(voucher, prices, total, total_quantity)
     assert discount == Money(expected_value, "USD")
 
 
+@pytest.mark.parametrize(
+    "prices, total, min_amount_spent, total_quantity, min_quantity_of_products, "
+    "discount_value_type, discount_value, voucher_type, apply_once_per_order, ",
+    [  # noqa
+        (
+            [5, 10, 15],
+            20,
+            50,
+            4,
+            10,
+            DiscountValueType.PERCENTAGE,
+            10,
+            VoucherType.PRODUCT,
+            False,
+        ),
+        (
+            [5, 10, 15],
+            20,
+            50,
+            4,
+            None,
+            DiscountValueType.PERCENTAGE,
+            10,
+            VoucherType.PRODUCT,
+            True,
+        ),
+        (
+            [5, 10, 15],
+            20,
+            None,
+            4,
+            10,
+            DiscountValueType.FIXED,
+            2,
+            VoucherType.SPECIFIC_PRODUCT,
+            False,
+        ),
+        (
+            [5, 10, 15],
+            20,
+            50,
+            4,
+            10,
+            DiscountValueType.FIXED,
+            2,
+            VoucherType.COLLECTION,
+            True,
+        ),
+    ],
+)
+def test_get_voucher_discount_all_products_not_applicable(
+    prices,
+    total,
+    min_amount_spent,
+    total_quantity,
+    min_quantity_of_products,
+    discount_value_type,
+    discount_value,
+    voucher_type,
+    apply_once_per_order,
+):
+    prices = [Money(price, "USD") for price in prices]
+    voucher = Voucher(
+        code="unique",
+        type=voucher_type,
+        discount_value_type=discount_value_type,
+        discount_value=discount_value,
+        apply_once_per_order=apply_once_per_order,
+        min_amount_spent=get_min_amount_spent(min_amount_spent),
+        min_quantity_of_products=min_quantity_of_products,
+    )
+    voucher.save()
+    total = Money(total, "USD")
+    with pytest.raises(NotApplicable):
+        get_products_voucher_discount(voucher, prices, total, total_quantity)
+
+
 date_time_now = timezone.now()
 
 


Code review comments:
diff_hunk: @@ -791,7 +792,12 @@ def _get_products_voucher_discount(checkout, voucher):
             "Voucher not applicable", "This offer is only valid for selected items."
         )
         raise NotApplicable(msg)
-    return get_products_voucher_discount(voucher, prices)
+    return get_products_voucher_discount(
+        voucher,
+        prices,
+        calculate_checkout_subtotal(checkout, discounts).gross,
path: saleor/checkout/utils.py
text: @user1:
Minor stylistic issue - if you moved this to a variable, we would end up with two lines instead of six:
```
subtotal = calculate_checkout_subtotal(checkout, discounts).gross
return get_products_voucher_discount(voucher, prices, subtotal, checkout.quantity)
```

diff_hunk: @@ -101,12 +101,15 @@ class VoucherInput(graphene.InputObjectType):
     min_amount_spent = Decimal(
         description="Min purchase amount required to apply the voucher."
     )
+    min_quantity_of_products = graphene.Int(
+        description="Minimal quantity of products required to apply the voucher."
path: saleor/graphql/discount/mutations.py
text: @user1:
This has to be a precise description. Minimal quantity of products but where? How is it calculated when I have the same product but twice? I think it would be more precise if we said:

```suggestion
        description="Minimal quantity of checkout items required to apply the voucher."
```

@user1:
Another question is whether the field name `min_quantity_of_products` is accurate enough? Maybe it should be called `min_checkout_items_quantity`?

@author:
Sure, I'm working on it.