From 6e24c20a7a46ddfc6ff42d1c7b9c60390dee2973 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 20 Nov 2022 14:20:40 +0100 Subject: [PATCH] Fix edge case in bundle price configuration --- src/pretix/base/models/items.py | 41 ++++--- src/pretix/base/models/orders.py | 1 + src/pretix/base/services/pricing.py | 5 +- src/tests/presale/test_bundle_prices.py | 152 ++++++++++++++++++++++++ 4 files changed, 178 insertions(+), 21 deletions(-) create mode 100644 src/tests/presale/test_bundle_prices.py diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 69a2511de0..169fa0f1f7 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -581,18 +581,15 @@ class Item(LoggedModel): def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False): price = price if price is not None else self.default_price - if not self.tax_rule: - t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), - rate=Decimal('0.00'), name='') - else: - t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address, - override_tax_rate=override_tax_rate, currency=currency or self.event.currency) - + bundled_sum = Decimal('0.00') + bundled_sum_net = Decimal('0.00') + bundled_sum_tax = Decimal('0.00') if include_bundled: for b in self.bundles.all(): if b.designated_price and b.bundled_item.tax_rule_id != self.tax_rule_id: if b.bundled_variation: - bprice = b.bundled_variation.tax(b.designated_price * b.count, base_price_is='gross', + bprice = b.bundled_variation.tax(b.designated_price * b.count, + base_price_is='gross', invoice_address=invoice_address, currency=currency) else: @@ -600,17 +597,23 @@ class Item(LoggedModel): invoice_address=invoice_address, base_price_is='gross', currency=currency) - if not self.tax_rule: - compare_price = TaxedPrice(gross=b.designated_price * b.count, net=b.designated_price * b.count, - tax=Decimal('0.00'), rate=Decimal('0.00'), name='') - else: - compare_price = self.tax_rule.tax(b.designated_price * b.count, - override_tax_rate=override_tax_rate, - invoice_address=invoice_address, - currency=currency) - t.net += bprice.net - compare_price.net - t.tax += bprice.tax - compare_price.tax - t.name = "MIXED!" + bundled_sum += bprice.gross + bundled_sum_net += bprice.net + bundled_sum_tax += bprice.tax + + if not self.tax_rule: + t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'), + rate=Decimal('0.00'), name='') + else: + t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address, + override_tax_rate=override_tax_rate, currency=currency or self.event.currency, + subtract_from_gross=bundled_sum) + + if bundled_sum: + t.name = "MIXED!" + t.gross += bundled_sum + t.net += bundled_sum_net + t.tax += bundled_sum_tax return t diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 40a87c19de..12b2b43c83 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2737,6 +2737,7 @@ class CartPosition(AbstractPosition): tax_rule=self.item.tax_rule, invoice_address=invoice_address, bundled_sum=sum([b.price_after_voucher for b in bundled_positions]), + is_bundled=self.is_bundled, ) if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate: self.line_price_gross = line_price.gross diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 91c95c38d2..577be08bb8 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -117,7 +117,7 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool, - tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal) -> TaxedPrice: + tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice: if not tax_rule: tax_rule = TaxRule( name='', @@ -135,7 +135,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate, invoice_address=invoice_address, subtract_from_gross=bundled_sum) else: - price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum) + price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum, + base_price_is='gross' if is_bundled else 'auto') return price diff --git a/src/tests/presale/test_bundle_prices.py b/src/tests/presale/test_bundle_prices.py new file mode 100644 index 0000000000..557fb0387d --- /dev/null +++ b/src/tests/presale/test_bundle_prices.py @@ -0,0 +1,152 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io 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 +# . +# + +import datetime +from decimal import Decimal + +from django.test import TestCase +from django.utils.timezone import now +from django_scopes import scopes_disabled + +from pretix.base.models import ( + CartPosition, Event, Item, OrderPosition, Organizer, Quota, +) +from pretix.base.services.orders import _perform_order +from pretix.testutils.sessions import get_cart_session_key + + +class BundlePricesTest(TestCase): + # This is an end to end test in addition to what's already tested in + # cart and checkout tests to ensure consistency + + @scopes_disabled() + def setUp(self): + super().setUp() + self.orga = Organizer.objects.create(name='CCC', slug='ccc') + self.event = Event.objects.create( + organizer=self.orga, name='30C3', slug='30c3', + date_from=datetime.datetime(now().year + 1, 12, 26, tzinfo=datetime.timezone.utc), + live=True, + plugins="pretix.plugins.banktransfer", + sales_channels=['web', 'bar'] + ) + self.tr19 = self.event.tax_rules.create(rate=Decimal('19.00')) + self.tr7 = self.event.tax_rules.create(rate=Decimal('7.00')) + + self.food = Item.objects.create(event=self.event, name='Food', + default_price=5, require_bundling=True, + tax_rule=self.tr7) + + self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', + default_price=23, + tax_rule=self.tr19) + + self.bundle = self.ticket.bundles.create( + bundled_item=self.food, designated_price=Decimal('10.00'), + ) + + self.quota_all = Quota.objects.create(event=self.event, name='All', size=None) + self.quota_all.items.add(self.ticket) + self.quota_all.items.add(self.food) + + self.session_key = get_cart_session_key(self.client, self.event) + + def test_simple_case(self): + # Verify correct price displayed on event page + response = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertContains(response, '23.00') + + # Verify correct price being added to cart + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1' + }, follow=True) + with scopes_disabled(): + cp1 = CartPosition.objects.get(is_bundled=False) + cp2 = CartPosition.objects.get(is_bundled=True) + + assert cp1.price == Decimal('13.00') + assert cp1.item == self.ticket + assert cp2.price == Decimal('10.00') + assert cp2.item == self.food + + # Make sure cart expires + cp1.expires = now() - datetime.timedelta(minutes=120) + cp1.save() + cp2.expires = now() - datetime.timedelta(minutes=120) + cp2.save() + + # Verify price is kept if cart expires and order is sent + with scopes_disabled(): + _perform_order(self.event, 'manual', [cp1.pk, cp2.pk], 'admin@example.org', 'en', None, {}, 'web') + op1 = OrderPosition.objects.get(is_bundled=False) + op2 = OrderPosition.objects.get(is_bundled=True) + assert op1.price == Decimal('13.00') + assert op1.item == self.ticket + assert op1.tax_rate == Decimal('19.00') + assert op2.price == Decimal('10.00') + assert op2.item == self.food + assert op2.tax_rate == Decimal('7.00') + + def test_net_price_definitions(self): + self.tr19.price_includes_tax = False + self.tr19.save() + self.tr7.price_includes_tax = False + self.tr7.save() + # Verify correct price displayed on event page + response = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertContains(response, '27.37') + + # Verify correct price displayed on event page in net mode + self.event.settings.display_net_prices = True + response = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertContains(response, '23.95') + + # Verify correct price being added to cart + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1' + }, follow=True) + with scopes_disabled(): + cp1 = CartPosition.objects.get(is_bundled=False) + cp2 = CartPosition.objects.get(is_bundled=True) + + assert cp1.price == Decimal('17.37') + assert cp1.item == self.ticket + assert cp2.price == Decimal('10.00') + assert cp2.item == self.food + + # Make sure cart expires + cp1.expires = now() - datetime.timedelta(minutes=120) + cp1.save() + cp2.expires = now() - datetime.timedelta(minutes=120) + cp2.save() + + # Verify price is kept if cart expires and order is sent + with scopes_disabled(): + _perform_order(self.event, 'manual', [cp1.pk, cp2.pk], 'admin@example.org', 'en', None, {}, 'web') + op1 = OrderPosition.objects.get(is_bundled=False) + op2 = OrderPosition.objects.get(is_bundled=True) + assert op1.price == Decimal('17.37') + assert op1.item == self.ticket + assert op1.tax_rate == Decimal('19.00') + assert op2.price == Decimal('10.00') + assert op2.item == self.food + assert op2.tax_rate == Decimal('7.00')