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")