diff --git a/doc/api/resources/discounts.rst b/doc/api/resources/discounts.rst index 9040be9c59..e7c126b003 100644 --- a/doc/api/resources/discounts.rst +++ b/doc/api/resources/discounts.rst @@ -31,9 +31,9 @@ subevent_mode strings Determines h ``"same"`` (discount is only applied for groups within the same date), or ``"distinct"`` (discount is only applied for groups with no two same dates). -condition_all_products boolean If ``true``, the discount applies to all items. +condition_all_products boolean If ``true``, the discount condition applies to all items. condition_limit_products list of integers If ``condition_all_products`` is not set, this is a list - of internal item IDs that the discount applies to. + of internal item IDs that the discount condition applies to. condition_apply_to_addons boolean If ``true``, the discount applies to add-on products as well, otherwise it only applies to top-level items. The discount never applies to bundled products. @@ -48,6 +48,17 @@ benefit_discount_matching_percent decimal (string) The percenta benefit_only_apply_to_cheapest_n_matches integer If set higher than 0, the discount will only be applied to the cheapest matches. Useful for a "3 for 2"-style discount. Cannot be combined with ``condition_min_value``. +benefit_same_products boolean If ``true``, the discount benefit applies to the same set of items + as the condition (see above). +benefit_limit_products list of integers If ``benefit_same_products`` is not set, this is a list + of internal item IDs that the discount benefit applies to. +benefit_apply_to_addons boolean (Only used if ``benefit_same_products`` is ``false``.) + If ``true``, the discount applies to add-on products as well, + otherwise it only applies to top-level items. The discount never + applies to bundled products. +benefit_ignore_voucher_discounted boolean (Only used if ``benefit_same_products`` is ``false``.) + If ``true``, the discount does not apply to products which have + been discounted by a voucher. ======================================== ========================== ======================================================= @@ -94,6 +105,10 @@ Endpoints "condition_ignore_voucher_discounted": false, "condition_min_count": 3, "condition_min_value": "0.00", + "benefit_same_products": true, + "benefit_limit_products": [], + "benefit_apply_to_addons": true, + "benefit_ignore_voucher_discounted": false, "benefit_discount_matching_percent": "100.00", "benefit_only_apply_to_cheapest_n_matches": 1 } @@ -146,6 +161,10 @@ Endpoints "condition_ignore_voucher_discounted": false, "condition_min_count": 3, "condition_min_value": "0.00", + "benefit_same_products": true, + "benefit_limit_products": [], + "benefit_apply_to_addons": true, + "benefit_ignore_voucher_discounted": false, "benefit_discount_matching_percent": "100.00", "benefit_only_apply_to_cheapest_n_matches": 1 } @@ -184,6 +203,10 @@ Endpoints "condition_ignore_voucher_discounted": false, "condition_min_count": 3, "condition_min_value": "0.00", + "benefit_same_products": true, + "benefit_limit_products": [], + "benefit_apply_to_addons": true, + "benefit_ignore_voucher_discounted": false, "benefit_discount_matching_percent": "100.00", "benefit_only_apply_to_cheapest_n_matches": 1 } @@ -211,6 +234,10 @@ Endpoints "condition_ignore_voucher_discounted": false, "condition_min_count": 3, "condition_min_value": "0.00", + "benefit_same_products": true, + "benefit_limit_products": [], + "benefit_apply_to_addons": true, + "benefit_ignore_voucher_discounted": false, "benefit_discount_matching_percent": "100.00", "benefit_only_apply_to_cheapest_n_matches": 1 } @@ -267,6 +294,10 @@ Endpoints "condition_ignore_voucher_discounted": false, "condition_min_count": 3, "condition_min_value": "0.00", + "benefit_same_products": true, + "benefit_limit_products": [], + "benefit_apply_to_addons": true, + "benefit_ignore_voucher_discounted": false, "benefit_discount_matching_percent": "100.00", "benefit_only_apply_to_cheapest_n_matches": 1 } diff --git a/src/pretix/api/serializers/discount.py b/src/pretix/api/serializers/discount.py index 449ca63f06..7e886ed644 100644 --- a/src/pretix/api/serializers/discount.py +++ b/src/pretix/api/serializers/discount.py @@ -32,11 +32,13 @@ class DiscountSerializer(I18nAwareModelSerializer): 'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products', 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches', - 'condition_ignore_voucher_discounted') + 'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons', + 'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['condition_limit_products'].queryset = self.context['event'].items.all() + self.fields['benefit_limit_products'].queryset = self.context['event'].items.all() def validate(self, data): data = super().validate(data) diff --git a/src/pretix/base/migrations/0245_discount_benefit_products.py b/src/pretix/base/migrations/0245_discount_benefit_products.py new file mode 100644 index 0000000000..7f8106f7ed --- /dev/null +++ b/src/pretix/base/migrations/0245_discount_benefit_products.py @@ -0,0 +1,34 @@ +# Generated by Django 4.2.4 on 2023-08-28 12:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pretixbase", "0244_mediumkeyset"), + ] + + operations = [ + migrations.AddField( + model_name="discount", + name="benefit_apply_to_addons", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="discount", + name="benefit_ignore_voucher_discounted", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="discount", + name="benefit_limit_products", + field=models.ManyToManyField( + related_name="benefit_discounts", to="pretixbase.item" + ), + ), + migrations.AddField( + model_name="discount", + name="benefit_same_products", + field=models.BooleanField(default=True), + ), + ] diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index 05aae1f40e..f5683c6975 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -99,7 +99,7 @@ class Discount(LoggedModel): ) condition_apply_to_addons = models.BooleanField( default=True, - verbose_name=_("Apply to add-on products"), + verbose_name=_("Count add-on products"), help_text=_("Discounts never apply to bundled products"), ) condition_ignore_voucher_discounted = models.BooleanField( @@ -107,7 +107,7 @@ class Discount(LoggedModel): verbose_name=_("Ignore products discounted by a voucher"), help_text=_("If this option is checked, products that already received a discount through a voucher will not " "be considered for this discount. However, products that use a voucher only to e.g. unlock a " - "hidden product or gain access to sold-out quota will still receive the discount."), + "hidden product or gain access to sold-out quota will still be considered."), ) condition_min_count = models.PositiveIntegerField( verbose_name=_('Minimum number of matching products'), @@ -120,6 +120,19 @@ class Discount(LoggedModel): default=Decimal('0.00'), ) + benefit_same_products = models.BooleanField( + default=True, + verbose_name=_("Apply discount to same set of products"), + help_text=_("By default, the discount is applied across the same selection of products than the condition for " + "the discount given above. If you want, you can however also select a different selection of " + "products.") + ) + benefit_limit_products = models.ManyToManyField( + 'Item', + verbose_name=_("Apply discount to specific products"), + related_name='benefit_discounts', + blank=True + ) benefit_discount_matching_percent = models.DecimalField( verbose_name=_('Percentual discount on matching products'), decimal_places=2, @@ -139,6 +152,18 @@ class Discount(LoggedModel): blank=True, validators=[MinValueValidator(1)], ) + benefit_apply_to_addons = models.BooleanField( + default=True, + verbose_name=_("Apply to add-on products"), + help_text=_("Discounts never apply to bundled products"), + ) + benefit_ignore_voucher_discounted = models.BooleanField( + default=False, + verbose_name=_("Ignore products discounted by a voucher"), + help_text=_("If this option is checked, products that already received a discount through a voucher will not " + "be discounted. However, products that use a voucher only to e.g. unlock a hidden product or gain " + "access to sold-out quota will still receive the discount."), + ) # more feature ideas: # - max_usages_per_order @@ -187,6 +212,14 @@ class Discount(LoggedModel): 'on a minimum value.') ) + if data.get('subevent_mode') == cls.SUBEVENT_MODE_DISTINCT and not data.get('benefit_same_products'): + raise ValidationError( + {'benefit_same_products': [ + _('You cannot apply the discount to a different set of products if the discount is only valid ' + 'for bookings of different dates.') + ]} + ) + def allow_delete(self): return not self.orderposition_set.exists() @@ -197,6 +230,7 @@ class Discount(LoggedModel): 'condition_min_value': self.condition_min_value, 'benefit_only_apply_to_cheapest_n_matches': self.benefit_only_apply_to_cheapest_n_matches, 'subevent_mode': self.subevent_mode, + 'benefit_same_products': self.benefit_same_products, }) def is_available_by_time(self, now_dt=None) -> bool: @@ -207,14 +241,14 @@ class Discount(LoggedModel): return False return True - def _apply_min_value(self, positions, idx_group, result): - if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value: + def _apply_min_value(self, positions, condition_idx_group, benefit_idx_group, result): + if self.condition_min_value and sum(positions[idx][2] for idx in condition_idx_group) < self.condition_min_value: return if self.condition_min_count or self.benefit_only_apply_to_cheapest_n_matches: raise ValueError('Validation invariant violated.') - for idx in idx_group: + for idx in benefit_idx_group: previous_price = positions[idx][2] new_price = round_decimal( previous_price * (Decimal('100.00') - self.benefit_discount_matching_percent) / Decimal('100.00'), @@ -222,8 +256,8 @@ class Discount(LoggedModel): ) result[idx] = new_price - def _apply_min_count(self, positions, idx_group, result): - if len(idx_group) < self.condition_min_count: + def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result): + if len(condition_idx_group) < self.condition_min_count: return if not self.condition_min_count or self.condition_min_value: @@ -233,15 +267,17 @@ class Discount(LoggedModel): if not self.condition_min_count: raise ValueError('Validation invariant violated.') - idx_group = sorted(idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price + condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price + benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx][2], -idx)) # sort by line_price # Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only # want to match multiples of 3 - consume_idx = idx_group[:len(idx_group) // self.condition_min_count * self.condition_min_count] - benefit_idx = idx_group[:len(idx_group) // self.condition_min_count * self.benefit_only_apply_to_cheapest_n_matches] + n_groups = min(len(condition_idx_group) // self.condition_min_count, len(benefit_idx_group)) + consume_idx = condition_idx_group[:n_groups * self.condition_min_count] + benefit_idx = benefit_idx_group[:n_groups * self.benefit_only_apply_to_cheapest_n_matches] else: - consume_idx = idx_group - benefit_idx = idx_group + consume_idx = condition_idx_group + benefit_idx = benefit_idx_group for idx in benefit_idx: previous_price = positions[idx][2] @@ -276,7 +312,7 @@ class Discount(LoggedModel): limit_products = {p.pk for p in self.condition_limit_products.all()} # First, filter out everything not even covered by our product scope - initial_candidates = [ + condition_candidates = [ idx for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items() if ( @@ -286,11 +322,25 @@ class Discount(LoggedModel): ) ] + if self.benefit_same_products: + benefit_candidates = list(condition_candidates) + else: + benefit_products = {p.pk for p in self.benefit_limit_products.all()} + benefit_candidates = [ + idx + for idx, (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) in positions.items() + if ( + item_id in benefit_products and + (self.benefit_apply_to_addons or not is_addon_to) and + (not self.benefit_ignore_voucher_discounted or voucher_discount is None or voucher_discount == Decimal('0.00')) + ) + ] + if self.subevent_mode == self.SUBEVENT_MODE_MIXED: # also applies to non-series events if self.condition_min_count: - self._apply_min_count(positions, initial_candidates, result) + self._apply_min_count(positions, condition_candidates, benefit_candidates, result) else: - self._apply_min_value(positions, initial_candidates, result) + self._apply_min_value(positions, condition_candidates, benefit_candidates, result) elif self.subevent_mode == self.SUBEVENT_MODE_SAME: def key(idx): @@ -299,17 +349,18 @@ class Discount(LoggedModel): # Build groups of candidates with the same subevent, then apply our regular algorithm # to each group - _groups = groupby(sorted(initial_candidates, key=key), key=key) - candidate_groups = [list(g) for k, g in _groups] + _groups = groupby(sorted(condition_candidates, key=key), key=key) + candidate_groups = [(k, list(g)) for k, g in _groups] - for g in candidate_groups: + for subevent_id, g in candidate_groups: + benefit_g = [idx for idx in benefit_candidates if positions[idx][1] == subevent_id] if self.condition_min_count: - self._apply_min_count(positions, g, result) + self._apply_min_count(positions, g, benefit_g, result) else: - self._apply_min_value(positions, g, result) + self._apply_min_value(positions, g, benefit_g, result) elif self.subevent_mode == self.SUBEVENT_MODE_DISTINCT: - if self.condition_min_value: + if self.condition_min_value or not self.benefit_same_products: raise ValueError('Validation invariant violated.') # Build optimal groups of candidates with distinct subevents, then apply our regular algorithm @@ -336,7 +387,7 @@ class Discount(LoggedModel): candidates = [] cardinality = None for se, l in subevent_to_idx.items(): - l = [ll for ll in l if ll in initial_candidates and ll not in current_group] + l = [ll for ll in l if ll in condition_candidates and ll not in current_group] if cardinality and len(l) != cardinality: continue if se not in {positions[idx][1] for idx in current_group}: @@ -373,5 +424,5 @@ class Discount(LoggedModel): break for g in candidate_groups: - self._apply_min_count(positions, g, result) + self._apply_min_count(positions, g, g, result) return result diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index e3e9ed3dd5..9d88f2b1d4 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -907,14 +907,18 @@ class Event(EventMixin, LoggedModel): self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q) for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'): - items = list(d.condition_limit_products.all()) + c_items = list(d.condition_limit_products.all()) + b_items = list(d.benefit_limit_products.all()) d.pk = None d.event = self d.save(force_insert=True) d.log_action('pretix.object.cloned') - for i in items: + for i in c_items: if i.pk in item_map: d.condition_limit_products.add(item_map[i.pk]) + for i in b_items: + if i.pk in item_map: + d.benefit_limit_products.add(item_map[i.pk]) question_map = {} for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index d7ed1e7f4e..50fcc73169 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -171,7 +171,7 @@ def apply_discounts(event: Event, sales_channel: str, Q(available_until__isnull=True) | Q(available_until__gte=now()), sales_channels__contains=sales_channel, active=True, - ).prefetch_related('condition_limit_products').order_by('position', 'pk') + ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') for discount in discount_qs: result = discount.apply({ idx: (item_id, subevent_id, line_price_gross, is_addon_to, voucher_discount) diff --git a/src/pretix/control/forms/discounts.py b/src/pretix/control/forms/discounts.py index cd1a4665d8..d61dd3aef5 100644 --- a/src/pretix/control/forms/discounts.py +++ b/src/pretix/control/forms/discounts.py @@ -50,11 +50,16 @@ class DiscountForm(I18nModelForm): 'condition_ignore_voucher_discounted', 'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches', + 'benefit_same_products', + 'benefit_limit_products', + 'benefit_apply_to_addons', + 'benefit_ignore_voucher_discounted', ] field_classes = { 'available_from': SplitDateTimeField, 'available_until': SplitDateTimeField, 'condition_limit_products': ItemMultipleChoiceField, + 'benefit_limit_products': ItemMultipleChoiceField, } widgets = { 'subevent_mode': forms.RadioSelect, @@ -64,11 +69,14 @@ class DiscountForm(I18nModelForm): 'data-inverse-dependency': '<[name$=all_products]', 'class': 'scrolling-multiple-choice', }), + 'benefit_limit_products': forms.CheckboxSelectMultiple(attrs={ + 'class': 'scrolling-multiple-choice', + }), 'benefit_only_apply_to_cheapest_n_matches': forms.NumberInput( attrs={ 'data-display-dependency': '#id_condition_min_count', } - ) + ), } def __init__(self, *args, **kwargs): @@ -85,6 +93,7 @@ class DiscountForm(I18nModelForm): widget=forms.CheckboxSelectMultiple, ) self.fields['condition_limit_products'].queryset = self.event.items.all() + self.fields['benefit_limit_products'].queryset = self.event.items.all() self.fields['condition_min_count'].required = False self.fields['condition_min_count'].widget.is_required = False self.fields['condition_min_value'].required = False diff --git a/src/pretix/control/templates/pretixcontrol/items/discount.html b/src/pretix/control/templates/pretixcontrol/items/discount.html index 24e7ed5976..a23805b51b 100644 --- a/src/pretix/control/templates/pretixcontrol/items/discount.html +++ b/src/pretix/control/templates/pretixcontrol/items/discount.html @@ -48,6 +48,12 @@
{% trans "Benefit" context "discount" %} + {% bootstrap_field form.benefit_same_products layout="control" %} +
+ {% bootstrap_field form.benefit_limit_products layout="control" %} + {% bootstrap_field form.benefit_apply_to_addons layout="control" %} + {% bootstrap_field form.benefit_ignore_voucher_discounted layout="control" %} +
{% bootstrap_field form.benefit_discount_matching_percent layout="control" addon_after="%" %} {% bootstrap_field form.benefit_only_apply_to_cheapest_n_matches layout="control" %}
diff --git a/src/tests/api/test_discounts.py b/src/tests/api/test_discounts.py index ccda04184c..355ae2e6a5 100644 --- a/src/tests/api/test_discounts.py +++ b/src/tests/api/test_discounts.py @@ -52,7 +52,11 @@ TEST_DISCOUNT_RES = { "condition_min_count": 3, "condition_min_value": "0.00", "benefit_discount_matching_percent": "100.00", - "benefit_only_apply_to_cheapest_n_matches": 1 + "benefit_only_apply_to_cheapest_n_matches": 1, + "benefit_same_products": True, + "benefit_limit_products": [], + "benefit_apply_to_addons": True, + "benefit_ignore_voucher_discounted": False, } diff --git a/src/tests/base/test_pricing_discount.py b/src/tests/base/test_pricing_discount.py index 6e23812af7..dce8801053 100644 --- a/src/tests/base/test_pricing_discount.py +++ b/src/tests/base/test_pricing_discount.py @@ -1012,3 +1012,304 @@ def test_available_until(event, item): ) assert sorted([p for p, d in apply_discounts(event, 'web', positions)]) == [Decimal('50.00'), Decimal('50.00')] + + +@pytest.mark.django_db +@scopes_disabled() +def test_discount_other_products_min_count(event, item, item2): + # "For every 5 item2, one item1 gets in for free" + d1 = Discount( + event=event, + condition_min_count=5, + condition_all_products=False, + benefit_discount_matching_percent=100, + benefit_only_apply_to_cheapest_n_matches=1, + benefit_same_products=False, + ) + d1.save() + d1.condition_limit_products.add(item2) + d1.benefit_limit_products.add(item) + + positions = ( + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')), + ) + expected = ( + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('90.00'), + Decimal('0.00'), + Decimal('0.00'), + ) + + new_prices = [p for p, d in apply_discounts(event, 'web', positions)] + assert sorted(new_prices) == sorted(expected) + + +@pytest.mark.django_db +@scopes_disabled() +def test_discount_other_products_min_count_no_addon(event, item, item2): + # "For every 2 item2, one item1 gets in for free, but no addons" + d1 = Discount( + event=event, + condition_min_count=2, + condition_all_products=False, + benefit_discount_matching_percent=100, + benefit_only_apply_to_cheapest_n_matches=1, + benefit_same_products=False, + benefit_apply_to_addons=False, + ) + d1.save() + d1.condition_limit_products.add(item2) + d1.benefit_limit_products.add(item) + + positions = ( + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('90.00'), True, False, Decimal('0.00')), + ) + expected = ( + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('90.00'), + Decimal('0.00'), + ) + + new_prices = [p for p, d in apply_discounts(event, 'web', positions)] + assert sorted(new_prices) == sorted(expected) + + +@pytest.mark.django_db +@scopes_disabled() +def test_discount_other_products_min_count_no_voucher(event, item, item2): + # "For every 2 item2, one item1 gets in for free, but no discount on already discounted" + d1 = Discount( + event=event, + condition_min_count=2, + condition_all_products=False, + benefit_discount_matching_percent=100, + benefit_only_apply_to_cheapest_n_matches=1, + benefit_same_products=False, + benefit_ignore_voucher_discounted=True, + ) + d1.save() + d1.condition_limit_products.add(item2) + d1.benefit_limit_products.add(item) + + positions = ( + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('40.00'), False, False, Decimal('50.00')), + (item.pk, None, Decimal('40.00'), False, False, Decimal('50.00')), + (item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')), + ) + expected = ( + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('40.00'), + Decimal('40.00'), + Decimal('0.00'), + ) + + new_prices = [p for p, d in apply_discounts(event, 'web', positions)] + assert sorted(new_prices) == sorted(expected) + + +@pytest.mark.django_db +@scopes_disabled() +def test_discount_subgroup_cheapest_n_min_count(event, item, item2): + # "For every 4 products, you get one free, but only of type item" + d1 = Discount( + event=event, + condition_min_count=4, + condition_all_products=False, + benefit_discount_matching_percent=100, + benefit_only_apply_to_cheapest_n_matches=1, + benefit_same_products=False, + ) + d1.save() + d1.condition_limit_products.add(item) + d1.condition_limit_products.add(item2) + d1.benefit_limit_products.add(item) + + positions = ( + # 11 items of item2, which contribute to the total count of 15, but do not get reduced + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('100.00'), False, False, Decimal('0.00')), + # 4 items of item, of which 3 of the cheapest will be reduced + (item.pk, None, Decimal('110.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('110.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('90.00'), False, False, Decimal('0.00')), + ) + expected = ( + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('100.00'), + Decimal('110.00'), + Decimal('0.00'), + Decimal('0.00'), + Decimal('0.00'), + ) + + new_prices = [p for p, d in apply_discounts(event, 'web', positions)] + assert sorted(new_prices) == sorted(expected) + + +@pytest.mark.django_db +@scopes_disabled() +def test_discount_other_products_min_value(event, item, item2): + # "If you buy item1 for at least €99, you get all item2 for 20% off" + d1 = Discount( + event=event, + condition_min_value=99, + condition_all_products=False, + benefit_discount_matching_percent=20, + benefit_same_products=False, + ) + d1.save() + d1.condition_limit_products.add(item) + d1.benefit_limit_products.add(item2) + + positions = ( + (item.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + ) + expected = ( + Decimal('50.00'), + Decimal('23.00'), + Decimal('23.00'), + Decimal('23.00'), + ) + + new_prices = [p for p, d in apply_discounts(event, 'web', positions)] + assert sorted(new_prices) == sorted(expected) + + positions = ( + (item.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + ) + expected = ( + Decimal('50.00'), + Decimal('50.00'), + Decimal('18.40'), + Decimal('18.40'), + Decimal('18.40'), + ) + + new_prices = [p for p, d in apply_discounts(event, 'web', positions)] + assert sorted(new_prices) == sorted(expected) + + +@pytest.mark.django_db +@scopes_disabled() +def test_multiple_discounts_with_benefit_condition_overlap(event, item, item2): + # "For every 5 item2, you get one item1 for 20 % off." + "For every two item1, you get one 10% off." + d1 = Discount( + event=event, + condition_min_count=5, + condition_all_products=False, + benefit_only_apply_to_cheapest_n_matches=1, + benefit_discount_matching_percent=20, + benefit_same_products=False, + position=1, + ) + d1.save() + d1.condition_limit_products.add(item2) + d1.benefit_limit_products.add(item) + + d2 = Discount( + event=event, + condition_min_count=2, + condition_all_products=False, + benefit_only_apply_to_cheapest_n_matches=1, + benefit_discount_matching_percent=10, + benefit_same_products=True, + position=2, + ) + d2.save() + d2.condition_limit_products.add(item) + + positions = ( + (item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item2.pk, None, Decimal('50.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + (item.pk, None, Decimal('23.00'), False, False, Decimal('0.00')), + ) + expected = ( + # item2 remains untouched + Decimal('50.00'), + Decimal('50.00'), + Decimal('50.00'), + Decimal('50.00'), + Decimal('50.00'), + Decimal('50.00'), + # one item is reduced 20% because we have >5 item2 + Decimal('18.40'), + # one item is reduced 10% because it's part of a group of two + Decimal('20.70'), + # two remain full price + Decimal('23.00'), + Decimal('23.00'), + ) + + new_prices = [p for p, d in apply_discounts(event, 'web', positions)] + assert sorted(new_prices) == sorted(expected)