diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 6d4a5291ad..490dc8bd93 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -510,7 +510,9 @@ class Item(LoggedModel): verbose_name=_("Free price input"), help_text=_("If this option is active, your users can choose the price themselves. The price configured above " "is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect " - "additional donations for your event.") + "additional donations for your event. We recommend against combining this feature with automatic " + "discounts since discounts are applied as the last step of price computation, which means that the " + "price entered by the customer will be modified again.") ) free_price_suggestion = models.DecimalField( verbose_name=_("Suggested price"), diff --git a/src/pretix/presale/productlist.py b/src/pretix/presale/productlist.py index 86fad53f86..a1cfaac149 100644 --- a/src/pretix/presale/productlist.py +++ b/src/pretix/presale/productlist.py @@ -21,7 +21,8 @@ # import sys from datetime import datetime -from typing import Optional +from decimal import Decimal +from typing import List, Optional, Union from django.conf import settings from django.db.models import ( @@ -29,11 +30,13 @@ from django.db.models import ( ) from django.db.models.lookups import Exact +from pretix.base.decimal import round_decimal from pretix.base.models import ( - ItemVariation, Quota, SalesChannel, SeatCategoryMapping, + Discount, Event, Item, ItemVariation, Quota, SalesChannel, + SeatCategoryMapping, SubEvent, Voucher, ) from pretix.base.models.items import ( - Item, ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation, + ItemAddOn, ItemBundle, SubEventItem, SubEventItemVariation, ) from pretix.base.services.quotas import QuotaAvailability from pretix.base.timemachine import time_machine_now @@ -54,6 +57,35 @@ def item_group_by_category(items): ) +def _single_item_discounts(event: Event, sales_channel: Union[str, SalesChannel], + subevent: SubEvent=None, voucher: Voucher=None, is_addons=False) -> List[Discount]: + discount_qs = event.discounts.filter( + Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()), + Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()), + Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=sales_channel), + active=True, + # Only discounts that can be applied before we know the full cart + benefit_same_products=True, + benefit_only_apply_to_cheapest_n_matches__isnull=True, + condition_min_value=Decimal("0.00"), + condition_min_count=1, + ).prefetch_related('condition_limit_products').order_by('position', 'pk') + + if subevent: + discount_qs = discount_qs.filter( + Q(subevent_date_from__isnull=True) | Q(subevent_date_from__lte=subevent.date_from), + Q(subevent_date_until__isnull=True) | Q(subevent_date_until__gte=subevent.date_from), + ) + + if is_addons: + discount_qs = discount_qs.filter(condition_apply_to_addons=True, benefit_apply_to_addons=True) + + if voucher and voucher.price_mode != "none": + discount_qs = discount_qs.filter(condition_ignore_voucher_discounted=False, benefit_ignore_voucher_discounted=False) + + return list(discount_qs) + + def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, voucher=None, require_seat=0, base_qs=None, allow_addons=False, allow_cross_sell=False, quota_cache=None, filter_items=None, filter_categories=None, memberships=None, @@ -190,6 +222,18 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v if filter_categories: items = items.filter(category_id__in=[a for a in filter_categories if a.isdigit()]) + # We pre-computate discounts that do not rely on specific combinations in the cart. + # This is not the same order of operations that is applied in the cart, so there could be some differences + # when it comes to tax rate handling, but we don't have that information in the product list anyway, so + # that is acceptable. + discounts = _single_item_discounts( + event=event, + sales_channel=channel, + voucher=voucher, + subevent=subevent, + is_addons=allow_addons, + ) + display_add_to_cart = False quota_cache_key = f'item_quota_cache:{subevent.id if subevent else 0}:{channel.identifier}:{bool(require_seat)}' quota_cache = quota_cache or event.cache.get(quota_cache_key) or {} @@ -272,6 +316,10 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v if resp: item.description += ("
" if item.description else "") + resp + matching_discounts = [ + d for d in discounts if d.condition_all_products or item in d.condition_limit_products.all() + ] + if not item.has_variations: item._remove = False if not bool(item._subevent_quotas): @@ -299,30 +347,51 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v max_per_order ) - original_price = item_price_override.get(item.pk, item.default_price) + configured_price = item_price_override.get(item.pk, item.default_price) voucher_reduced = False if voucher: - price = voucher.calculate_price(original_price) - voucher_reduced = price < original_price + price = voucher.calculate_price(configured_price) + voucher_reduced = price < configured_price include_bundled = not voucher.all_bundles_included else: - price = original_price + price = configured_price include_bundled = True - item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled) + if matching_discounts and not item.free_price: + # Discounts and free prices are a non-recommended combination that behaves unintuitively, so we can + # accept it not being handled here. + discount = matching_discounts[0] # First matching discount rule always wins + # First handle taxes because discount is always computed on gross + taxed_price = item.tax(price, currency=event.currency, include_bundled=include_bundled) + price_without_bundles = taxed_price.gross - sum(b.count * b.designated_price for b in item.bundles.all()) + discounted_price = round_decimal( + taxed_price.gross - price_without_bundles * discount.benefit_discount_matching_percent / Decimal('100.00'), + event.currency, + ) + item.display_price = item.tax(discounted_price, currency=event.currency, include_bundled=include_bundled, + base_price_is='gross') + else: + discount = None + item.display_price = item.tax(price, currency=event.currency, include_bundled=include_bundled) + if item.free_price and item.free_price_suggestion is not None and not voucher_reduced: item.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, include_bundled=include_bundled) else: item.suggested_price = item.display_price - if price != original_price: - item.original_price = item.tax(original_price, currency=event.currency, include_bundled=True) - else: - item.original_price = ( - item.tax(item.original_price, currency=event.currency, include_bundled=True, - base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat - if item.original_price else None + if voucher_reduced or (discount and not item.original_price): + # If a voucher is used, we use the non-voucher price as the "original price", even if a different + # original price is set, to highlight the voucher's impact. We also use the configured price as + # original price if a discount is applied and no explicit original price is set + item.original_price = item.tax(configured_price, currency=event.currency, include_bundled=True) + elif item.original_price: + item.original_price = item.tax( + item.original_price, currency=event.currency, include_bundled=True, + base_price_is='net' if event.settings.display_net_prices else 'gross' # backwards-compat ) + else: + item.original_price = None + if not display_add_to_cart: display_add_to_cart = not item.requires_seat and item.order_max > 0 else: @@ -352,35 +421,53 @@ def prepare_item_list_for_shop(event, *, channel: SalesChannel, subevent=None, v max_per_order ) - original_price = var_price_override.get(var.pk, var.price) + configured_price = var_price_override.get(var.pk, var.price) voucher_reduced = False if voucher: - price = voucher.calculate_price(original_price) - voucher_reduced = price < original_price + price = voucher.calculate_price(configured_price) + voucher_reduced = price < configured_price include_bundled = not voucher.all_bundles_included else: - price = original_price + price = configured_price include_bundled = True - var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled) + if matching_discounts and not item.free_price: + # Discounts and free prices are a non-recommended combination that behaves unintuitively, so we can + # accept it not being handled here. + discount = matching_discounts[0] # First matching discount rule always wins + # First handle taxes because discount is always computed on gross + taxed_price = var.tax(price, currency=event.currency, include_bundled=include_bundled) + price_without_bundles = taxed_price.gross - sum(b.count * b.designated_price for b in item.bundles.all()) + discounted_price = round_decimal( + taxed_price.gross - price_without_bundles * discount.benefit_discount_matching_percent / Decimal('100.00'), + event.currency, + ) + var.display_price = var.tax(discounted_price, currency=event.currency, + include_bundled=include_bundled, + base_price_is='gross') + else: + discount = None + var.display_price = var.tax(price, currency=event.currency, include_bundled=include_bundled) if item.free_price and var.free_price_suggestion is not None and not voucher_reduced: - var.suggested_price = item.tax(max(price, var.free_price_suggestion), currency=event.currency, - include_bundled=include_bundled) + var.suggested_price = var.tax(max(price, var.free_price_suggestion), currency=event.currency, + include_bundled=include_bundled) elif item.free_price and item.free_price_suggestion is not None and not voucher_reduced: - var.suggested_price = item.tax(max(price, item.free_price_suggestion), currency=event.currency, - include_bundled=include_bundled) + var.suggested_price = var.tax(max(price, item.free_price_suggestion), currency=event.currency, + include_bundled=include_bundled) else: var.suggested_price = var.display_price - if price != original_price: - var.original_price = var.tax(original_price, currency=event.currency, include_bundled=True) + if voucher_reduced or (discount and not item.original_price and not var.original_price): + var.original_price = var.tax(configured_price, currency=event.currency, include_bundled=True) + elif item.original_price or var.original_price: + var.original_price = var.tax( + var.original_price or item.original_price, currency=event.currency, + include_bundled=True, + base_price_is='net' if event.settings.display_net_prices else 'gross' # backwards-compat + ) else: - var.original_price = ( - var.tax(var.original_price or item.original_price, currency=event.currency, - include_bundled=True, - base_price_is='net' if event.settings.display_net_prices else 'gross') # backwards-compat - ) if var.original_price or item.original_price else None + var.original_price = None var.current_unavailability_reason = _get_variant_unavailability_reason(var, has_voucher=voucher, subevent=subevent) diff --git a/src/tests/presale/test_productlist.py b/src/tests/presale/test_productlist.py new file mode 100644 index 0000000000..5543d1711b --- /dev/null +++ b/src/tests/presale/test_productlist.py @@ -0,0 +1,281 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-today pretix GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# + +from datetime import UTC, datetime, timedelta +from decimal import Decimal + +import pytest +from django.utils.timezone import now +from django_scopes import scope + +from pretix.base.models import Event, Organizer +from pretix.presale.productlist import prepare_item_list_for_shop + +# Tests for prepare_item_list_for_shop are really incomplete since historically, most features are +# tested on the test_event or test_widget layer. We'll slowly add new tests here to test closer to the +# source. + + +@pytest.fixture +def event(): + o = Organizer.objects.create(name='MRMCD', slug='mrmcd') + e = Event.objects.create( + organizer=o, name='MRMCD2015', slug='2015', + date_from=now(), live=True + ) + with scope(organizer=o): + yield e + + +@pytest.fixture +def quota(event): + return event.quotas.create(name="Tickets", size=500) + + +@pytest.fixture +def item(event, quota): + i = event.items.create(name="Ticket", default_price=Decimal("42.00")) + quota.items.add(i) + return i + + +@pytest.fixture +def variation(event, quota): + i = event.items.create(name="Ticket with variants", default_price=Decimal("99.00")) + v = i.variations.create(value="Default", default_price=Decimal("42.00")) + quota.items.add(i) + quota.variations.add(v) + return v + + +@pytest.fixture +def discount(event): + return event.discounts.create( + internal_name="Early-Bird-Discount", + all_sales_channels=True, + available_from=now() - timedelta(days=2), + available_until=now() + timedelta(days=2), + condition_all_products=True, + condition_min_count=1, + benefit_discount_matching_percent=Decimal("10.00"), + ) + + +@pytest.fixture +def channel(event): + return event.organizer.sales_channels.get(identifier="web") + + +@pytest.mark.django_db +def test_default_price(event, item, variation, channel): + items, _ = prepare_item_list_for_shop(event, channel=channel) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("42") + assert items[1].available_variations[0].display_price.gross == Decimal("42") + + +def _test_no_discount(event, channel): + items, _ = prepare_item_list_for_shop(event, channel=channel) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("42") + assert items[1].available_variations[0].display_price.gross == Decimal("42") + + +@pytest.mark.django_db +def test_discount_applied(event, item, variation, channel, discount): + items, _ = prepare_item_list_for_shop(event, channel=channel) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("37.80") + assert items[0].original_price.gross == Decimal("42.00") + assert items[1].available_variations[0].display_price.gross == Decimal("37.80") + assert items[1].available_variations[0].original_price.gross == Decimal("42.00") + + +@pytest.mark.django_db +def test_discount_for_groups_ignored(event, item, variation, channel, discount): + discount.condition_min_count = 2 + discount.save() + _test_no_discount(event, channel) + + +@pytest.mark.django_db +def test_discount_original_price_kept(event, item, variation, channel, discount): + item.original_price = Decimal("46.00") + item.save() + variation.original_price = Decimal("46.00") + variation.save() + items, _ = prepare_item_list_for_shop(event, channel=channel) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("37.80") + assert items[0].original_price.gross == Decimal("46.00") + assert items[1].available_variations[0].display_price.gross == Decimal("37.80") + assert items[1].available_variations[0].original_price.gross == Decimal("46.00") + + +@pytest.mark.django_db +def test_discount_out_of_timeframe(event, item, variation, channel, discount): + discount.available_from = now() + timedelta(days=2) + discount.save() + _test_no_discount(event, channel) + + +@pytest.mark.django_db +def test_discount_wrong_channel(event, item, variation, channel, discount): + discount.all_sales_channels = False + discount.save() + _test_no_discount(event, channel) + + +@pytest.mark.django_db +def test_discount_benefits_other_products_ignored(event, item, variation, channel, discount): + discount.benefit_same_products = False + discount.save() + _test_no_discount(event, channel) + + +@pytest.mark.django_db +def test_discounts_for_addons(event, item, variation, channel, discount): + discount.condition_apply_to_addons = False + discount.save() + items, _ = prepare_item_list_for_shop(event, channel=channel, allow_addons=True) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("42.00") + assert items[1].available_variations[0].display_price.gross == Decimal("42.00") + + discount.condition_apply_to_addons = True + discount.save() + items, _ = prepare_item_list_for_shop(event, channel=channel, allow_addons=True) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("37.80") + assert items[1].available_variations[0].display_price.gross == Decimal("37.80") + + +@pytest.mark.django_db +def test_discounts_with_voucher(event, item, variation, channel, discount): + voucher = event.vouchers.create(code="FOO", price_mode="subtract", value=Decimal("10.00")) + voucher2 = event.vouchers.create(code="BAR") + discount.condition_ignore_voucher_discounted = True + discount.save() + item.original_price = Decimal("46.00") + item.save() + variation.item.original_price = Decimal("46.00") + variation.item.save() + items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("32.00") + assert items[0].original_price.gross == Decimal("42.00") + assert items[1].available_variations[0].display_price.gross == Decimal("32.00") + assert items[1].available_variations[0].original_price.gross == Decimal("42.00") + items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher2) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("37.80") + assert items[0].original_price.gross == Decimal("46.00") + assert items[1].available_variations[0].display_price.gross == Decimal("37.80") + assert items[1].available_variations[0].original_price.gross == Decimal("46.00") + + discount.condition_ignore_voucher_discounted = False + discount.save() + items, _ = prepare_item_list_for_shop(event, channel=channel, voucher=voucher) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("28.80") + assert items[0].original_price.gross == Decimal("42.00") + assert items[1].available_variations[0].display_price.gross == Decimal("28.80") + assert items[1].available_variations[0].original_price.gross == Decimal("42.00") + + +@pytest.mark.django_db +def test_discounts_for_subevent_timeframe(event, quota, item, variation, channel, discount): + event.has_subevents = True + event.save() + se = event.subevents.create( + name="Foobar", date_from=datetime(2028, 12, 27, 10, 0, 0, tzinfo=UTC) + ) + quota.subevent = se + quota.save() + + discount.subevent_date_from = se.date_from + timedelta(days=1) + discount.save() + items, _ = prepare_item_list_for_shop(event, channel=channel, subevent=se) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("42.00") + assert items[1].available_variations[0].display_price.gross == Decimal("42.00") + + discount.subevent_date_from = se.date_from - timedelta(days=1) + discount.save() + items, _ = prepare_item_list_for_shop(event, channel=channel, subevent=se) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("37.80") + assert items[1].available_variations[0].display_price.gross == Decimal("37.80") + + +@pytest.mark.django_db +def test_discounts_for_products(event, quota, item, variation, channel, discount): + discount.condition_all_products = False + discount.save() + items, _ = prepare_item_list_for_shop(event, channel=channel) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("42.00") + assert items[1].available_variations[0].display_price.gross == Decimal("42.00") + + discount.condition_limit_products.add(item) + discount.save() + items, _ = prepare_item_list_for_shop(event, channel=channel) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("37.80") + assert items[1].available_variations[0].display_price.gross == Decimal("37.80") + + +@pytest.mark.django_db +def test_discounts_for_products_tax_additive(event, quota, item, variation, channel, discount): + tr = event.tax_rules.create(rate=Decimal("19.00"), price_includes_tax=False) + item.tax_rule = tr + item.save() + variation.item.tax_rule = tr + variation.item.save() + items, _ = prepare_item_list_for_shop(event, channel=channel) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("44.98") + assert items[0].original_price.gross == Decimal("49.98") + assert items[0].original_price.net == Decimal("42.00") + assert items[1].available_variations[0].display_price.gross == Decimal("44.98") + assert items[1].available_variations[0].original_price.gross == Decimal("49.98") + assert items[1].available_variations[0].original_price.net == Decimal("42.00") + + +@pytest.mark.django_db +def test_discounts_for_products_tax_additive_bundle_included(event, quota, item, variation, channel, discount): + tr = event.tax_rules.create(rate=Decimal("19.00"), price_includes_tax=False) + b = event.items.create(name="Bundled product", default_price=Decimal("10.00"), tax_rule=tr, require_bundling=True) + quota.items.add(b) + item.tax_rule = tr + item.save() + variation.item.tax_rule = tr + variation.item.save() + item.bundles.create(bundled_item=b, count=2, designated_price=Decimal("5.00")) + items, _ = prepare_item_list_for_shop(event, channel=channel) + assert len(items) == 2 + assert items[0].display_price.gross == Decimal("45.98") + assert items[0].original_price.gross == Decimal("49.98") + assert items[0].original_price.net == Decimal("42.00") + assert items[1].available_variations[0].display_price.gross == Decimal("45.98") + assert items[1].available_variations[0].original_price.gross == Decimal("49.98") + assert items[1].available_variations[0].original_price.net == Decimal("42.00")