From 14ed6982a5be60fe64f333a0d1e2e6f8e6aaf10b Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Mon, 30 Jun 2025 16:47:09 +0200 Subject: [PATCH] New data model for default tax rule and new options for cancellation fees (#4962) * New data model for default tax rule * Remove misleading empty label when field is not optional * Allow to split cancellation fee * Fix API and tests * Update migration * Update src/tests/api/test_taxrules.py Co-authored-by: luelista * Update src/tests/api/test_taxrules.py Co-authored-by: luelista * Review note * Update src/pretix/base/models/tax.py Co-authored-by: luelista * Flip API behaviour for default * Fix failing tests * Fix failing test * Split migration --------- Co-authored-by: luelista --- doc/api/resources/taxrules.rst | 9 +++ src/pretix/api/serializers/event.py | 26 ++++++- src/pretix/api/views/event.py | 5 ++ .../base/migrations/0282_taxrule_default.py | 24 ++++++ .../0283_taxrule_default_taxrule_backfill.py | 60 +++++++++++++++ src/pretix/base/models/event.py | 11 +-- src/pretix/base/models/orders.py | 8 +- src/pretix/base/models/tax.py | 13 +++- src/pretix/base/services/cancelevent.py | 39 +++++++--- src/pretix/base/services/cart.py | 5 +- src/pretix/base/services/orders.py | 47 ++++++++--- src/pretix/base/services/tax.py | 67 +++++++++++++++- src/pretix/base/settings.py | 46 ++++++++++- src/pretix/control/forms/event.py | 13 +--- src/pretix/control/forms/item.py | 3 +- src/pretix/control/forms/orders.py | 16 +++- .../templates/pretixcontrol/event/cancel.html | 1 + .../pretixcontrol/event/payment.html | 2 +- .../pretixcontrol/event/tax_delete.html | 5 +- .../pretixcontrol/event/tax_index.html | 17 ++++ src/pretix/control/urls.py | 1 + src/pretix/control/views/event.py | 48 +++++++++++- src/pretix/control/views/main.py | 18 +++-- src/tests/api/conftest.py | 2 +- src/tests/api/test_orders.py | 4 +- src/tests/api/test_taxrules.py | 40 ++++++++++ src/tests/base/test_models.py | 5 +- src/tests/base/test_orders.py | 26 +++---- src/tests/base/test_taxrules.py | 41 +++++++++- src/tests/control/test_events.py | 16 ++-- src/tests/control/test_orders.py | 77 ++++++++++++++++++- src/tests/control/test_permissions.py | 2 + src/tests/control/test_taxrates.py | 19 ++++- src/tests/presale/test_checkout.py | 3 +- 34 files changed, 615 insertions(+), 104 deletions(-) create mode 100644 src/pretix/base/migrations/0282_taxrule_default.py create mode 100644 src/pretix/base/migrations/0283_taxrule_default_taxrule_backfill.py diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst index 2947015f1b..a8e6b72390 100644 --- a/doc/api/resources/taxrules.rst +++ b/doc/api/resources/taxrules.rst @@ -26,6 +26,8 @@ rate decimal (string) Tax rate in per code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`. price_includes_tax boolean If ``true`` (default), tax is assumed to be included in the specified product price +default boolean If ``true`` (default), this is the default tax rate for this event + (there can only be one per event). eu_reverse_charge boolean **DEPRECATED**. If ``true``, EU reverse charge rules are applied. Will be ignored if custom rules are set. Use custom rules instead. @@ -48,6 +50,10 @@ custom_rules object Dynamic rules s The ``code`` attribute has been added. +.. versionchanged:: 2025.4 + + The ``default`` attribute has been added. + .. _rest-taxcodes: Tax codes @@ -111,6 +117,7 @@ Endpoints { "id": 1, "name": {"en": "VAT"}, + "default": true, "internal_name": "VAT", "code": "S/standard", "rate": "19.00", @@ -153,6 +160,7 @@ Endpoints { "id": 1, "name": {"en": "VAT"}, + "default": true, "internal_name": "VAT", "code": "S/standard", "rate": "19.00", @@ -203,6 +211,7 @@ Endpoints { "id": 1, "name": {"en": "VAT"}, + "default": false, "internal_name": "VAT", "code": "S/standard", "rate": "19.00", diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 6161221793..f7351a805d 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -685,8 +685,26 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer): class Meta: model = TaxRule - fields = ('id', 'name', 'rate', 'code', 'price_includes_tax', 'eu_reverse_charge', 'home_country', - 'internal_name', 'keep_gross_if_rate_changes', 'custom_rules') + fields = ('id', 'name', 'default', 'rate', 'code', 'price_includes_tax', 'eu_reverse_charge', 'home_country', + 'internal_name', 'keep_gross_if_rate_changes', 'custom_rules', 'default') + + def create(self, validated_data): + if "default" not in validated_data and not self.context["event"].tax_rules.exists(): + validated_data["default"] = True + return super().create(validated_data) + + def save(self, **kwargs): + if self.validated_data.get("default"): + if self.instance and self.instance.pk: + self.context["event"].tax_rules.exclude(pk=self.instance.pk).update(default=False) + else: + self.context["event"].tax_rules.update(default=False) + return super().save(**kwargs) + + def validate_default(self, value): + if not value and self.instance.default: + raise ValidationError("You can't remove the default property, instead set it on another tax rule.") + return value class EventSettingsSerializer(SettingsSerializer): @@ -712,6 +730,8 @@ class EventSettingsSerializer(SettingsSerializer): 'allow_modifications_after_checkin', 'last_order_modification_date', 'show_quota_left', + 'tax_rule_payment', + 'tax_rule_cancellation', 'waiting_list_enabled', 'waiting_list_auto_disable', 'waiting_list_hours', @@ -942,6 +962,8 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'reusable_media_type_nfc_mf0aes', 'reusable_media_type_nfc_mf0aes_random_uid', 'system_question_order', + 'tax_rule_payment', + 'tax_rule_cancellation', ] def __init__(self, *args, **kwargs): diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 659d41e3c1..19b2fb7d20 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -580,6 +580,11 @@ class TaxRuleViewSet(ConditionalListView, viewsets.ModelViewSet): ) super().perform_destroy(instance) + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx["event"] = self.request.event + return ctx + class ItemMetaPropertiesViewSet(viewsets.ModelViewSet): serializer_class = ItemMetaPropertiesSerializer diff --git a/src/pretix/base/migrations/0282_taxrule_default.py b/src/pretix/base/migrations/0282_taxrule_default.py new file mode 100644 index 0000000000..bfda929212 --- /dev/null +++ b/src/pretix/base/migrations/0282_taxrule_default.py @@ -0,0 +1,24 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0281_event_is_remote"), + ] + + operations = [ + migrations.AddField( + model_name="taxrule", + name="default", + field=models.BooleanField(default=False), + ), + migrations.AddConstraint( + model_name="taxrule", + constraint=models.UniqueConstraint( + condition=models.Q(("default", True)), + fields=("event",), + name="one_default_per_event", + ), + ), + ] diff --git a/src/pretix/base/migrations/0283_taxrule_default_taxrule_backfill.py b/src/pretix/base/migrations/0283_taxrule_default_taxrule_backfill.py new file mode 100644 index 0000000000..c3a66f3652 --- /dev/null +++ b/src/pretix/base/migrations/0283_taxrule_default_taxrule_backfill.py @@ -0,0 +1,60 @@ +# Generated by Django 4.2.17 on 2025-03-28 09:19 +from django.core.cache import cache +from django.db import migrations, models +from django.db.models import Count, Exists, OuterRef + + +def set_default_tax_rate(app, schema_editor): + Event = app.get_model('pretixbase', 'Event') + Event_SettingsStore = app.get_model('pretixbase', 'Event_SettingsStore') + TaxRule = app.get_model('pretixbase', 'TaxRule') + + # Handling of events with tax_rate_default set + for s in Event_SettingsStore.objects.filter(key="tax_rate_default"): + updated = TaxRule.objects.filter(pk=s.value, event_id=s.object_id).update(default=True) + if updated: + # Delete deprecated settings key + s.delete() + + # The default for new events is tax_rule_cancellation=none, but since we do not change behaviour + # for existing events without warning, we create a settings entry that matches the old behaviour. + Event_SettingsStore.objects.get_or_create( + object_id=s.object_id, + key="tax_rule_cancellation", + defaults={"value": "default"}, + ) + + # We do not need to set tax_rule_payment here since "default" is the default + + cache.delete('hierarkey_{}_{}'.format('event', s.object_id)) + + # Handling of events with tax_rate_default not set + for e in Event.objects.only("pk").exclude(Exists(TaxRule.objects.filter(default=True, event_id=OuterRef("pk")))): + fav_tax_rules = e.tax_rules.annotate(c=Count("item")).order_by("-c", "pk")[:1] + if fav_tax_rules: + fav_tax_rules[0].default = True + fav_tax_rules[0].save() + + # Previously, no tax rule was set for payments, so keep it this way + Event_SettingsStore.objects.get_or_create( + object=e, + key="tax_rule_payment", + defaults={"value": "none"}, + ) + cache.delete('hierarkey_{}_{}'.format('event', e.pk)) + + # We do not need to set tax_rule_cancellation, as "none" is the new system default + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0282_taxrule_default"), + ] + + operations = [ + migrations.RunPython( + set_default_tax_rate, + migrations.RunPython.noop, + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 97e0c1ec56..7f28f2e97f 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -1113,13 +1113,6 @@ class Event(EventMixin, LoggedModel): newname = default_storage.save(fname, fi) s.value = 'file://' + newname settings_to_save.append(s) - elif s.key == 'tax_rate_default': - try: - if int(s.value) in tax_map: - s.value = tax_map.get(int(s.value)).pk - settings_to_save.append(s) - except ValueError: - pass elif s.key.startswith('payment_') and s.key.endswith('__restrict_to_sales_channels'): data = other.settings._unserialize(s.value, as_type=list) data = [ident for ident in data if ident in valid_sales_channel_identifers] @@ -1198,6 +1191,10 @@ class Event(EventMixin, LoggedModel): renderers[pp.identifier] = pp return renderers + @cached_property + def cached_default_tax_rule(self): + return self.tax_rules.filter(default=True).first() + @cached_property def ticket_secret_generators(self) -> dict: """ diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 9db278422a..9a8d5ea7c1 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2373,17 +2373,17 @@ class OrderFee(models.Model): self.fee_type, self.value ) - def _calculate_tax(self, tax_rule=None): + def _calculate_tax(self, tax_rule=None, invoice_address=None): if tax_rule: self.tax_rule = tax_rule try: - ia = self.order.invoice_address + ia = invoice_address or self.order.invoice_address except InvoiceAddress.DoesNotExist: ia = None - if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rate_default: - self.tax_rule = self.order.event.settings.tax_rate_default + if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default": + self.tax_rule = self.order.event.cached_default_tax_rule if self.tax_rule: tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True) diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index af4c5ed481..1058934cd1 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -377,9 +377,20 @@ class TaxRule(LoggedModel): 'if configured above.'), ) custom_rules = models.TextField(blank=True, null=True) + default = models.BooleanField( + verbose_name=_('Default'), + default=False, + ) class Meta: ordering = ('event', 'rate', 'id') + constraints = [ + models.UniqueConstraint( + fields=["event"], + condition=models.Q(default=True), + name="one_default_per_event", + ), + ] class SaleNotAllowed(Exception): pass @@ -394,7 +405,7 @@ class TaxRule(LoggedModel): and not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists() and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists() and not self.event.items.filter(tax_rule=self).exists() - and self.event.settings.tax_rate_default != self + and not (self.default and self.event.tax_rules.filter(~models.Q(pk=self.pk)).exists()) ) @classmethod diff --git a/src/pretix/base/services/cancelevent.py b/src/pretix/base/services/cancelevent.py index 1211412e7c..3f14c91dc6 100644 --- a/src/pretix/base/services/cancelevent.py +++ b/src/pretix/base/services/cancelevent.py @@ -32,7 +32,7 @@ from pretix.base.email import get_email_context from pretix.base.i18n import language from pretix.base.models import ( Event, InvoiceAddress, Order, OrderFee, OrderPosition, OrderRefund, - SubEvent, User, WaitingListEntry, + SubEvent, TaxRule, User, WaitingListEntry, ) from pretix.base.services.locking import LockTimeoutException from pretix.base.services.mail import SendMailException, mail @@ -40,6 +40,7 @@ from pretix.base.services.orders import ( OrderChangeManager, OrderError, _cancel_order, _try_auto_refund, ) from pretix.base.services.tasks import ProfiledEventTask +from pretix.base.services.tax import split_fee_for_taxes from pretix.celery_app import app from pretix.helpers import OF_SELF from pretix.helpers.format import format_map @@ -268,14 +269,34 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool, fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total fee = round_decimal(min(fee, o.payment_refund_sum), event.currency) if fee: - f = OrderFee( - fee_type=OrderFee.FEE_TYPE_CANCELLATION, - value=fee, - order=o, - tax_rule=o.event.settings.tax_rate_default, - ) - f._calculate_tax() - ocm.add_fee(f) + tax_rule_zero = TaxRule.zero() + if event.settings.tax_rule_cancellation == "default": + fee_values = [(event.cached_default_tax_rule or tax_rule_zero, fee)] + elif event.settings.tax_rule_cancellation == "split": + fee_values = split_fee_for_taxes(positions, fee, event) + else: + fee_values = [(tax_rule_zero, fee)] + + try: + ia = o.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None + + for tax_rule, price in fee_values: + tax_rule = tax_rule or tax_rule_zero + tax = tax_rule.tax( + price, invoice_address=ia, base_price_is="gross" + ) + f = OrderFee( + fee_type=OrderFee.FEE_TYPE_CANCELLATION, + value=price, + order=o, + tax_rate=tax.rate, + tax_code=tax.code, + tax_value=tax.tax, + tax_rule=tax_rule, + ) + ocm.add_fee(f) ocm.commit() refund_amount = o.payment_refund_sum - o.total diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 5212b1307d..3b256bd62c 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1534,7 +1534,10 @@ def get_fees(event, request, total, invoice_address, payments, positions): total_remaining -= to_pay if payment_fee: - payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero() + if event.settings.tax_rule_payment == "default": + payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero() + else: + payment_fee_tax_rule = TaxRule.zero() payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address) fees.append(OrderFee( fee_type=OrderFee.FEE_TYPE_PAYMENT, diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 32d14da3e8..200b8a9f24 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -62,6 +62,7 @@ from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy from django_scopes import scopes_disabled from pretix.api.models import OAuthApplication +from pretix.base.decimal import round_decimal from pretix.base.email import get_email_context from pretix.base.i18n import get_language_without_region, language from pretix.base.media import MEDIA_TYPES @@ -96,6 +97,7 @@ from pretix.base.services.pricing import ( ) from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask +from pretix.base.services.tax import split_fee_for_taxes from pretix.base.signals import ( order_approved, order_canceled, order_changed, order_denied, order_expired, order_expiry_changed, order_fee_calculation, order_paid, order_placed, @@ -486,7 +488,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None): def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None, - cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None): + cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None, tax_mode=None): """ Mark this order as canceled :param order: The order to change @@ -506,6 +508,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device oauth_application = OAuthApplication.objects.get(pk=oauth_application) if isinstance(cancellation_fee, str): cancellation_fee = Decimal(cancellation_fee) + elif isinstance(cancellation_fee, (float, int)): + cancellation_fee = round_decimal(cancellation_fee, order.event.currency) + + tax_mode = tax_mode or order.event.settings.tax_rule_cancellation if not order.cancel_allowed(): raise OrderError(_('You cannot cancel this order.')) @@ -533,7 +539,9 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device m.save() if cancellation_fee: + positions = [] for position in order.positions.all(): + positions.append(position) if position.voucher: Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1)) position.canceled = True @@ -546,18 +554,39 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device if keep_fees and fee in keep_fees: new_fee -= fee.value else: + positions.append(fee) fee.canceled = True fee.save(update_fields=['canceled']) if new_fee: - f = OrderFee( - fee_type=OrderFee.FEE_TYPE_CANCELLATION, - value=new_fee, - tax_rule=order.event.settings.tax_rate_default, - order=order, - ) - f._calculate_tax() - f.save() + tax_rule_zero = TaxRule.zero() + if tax_mode == "default": + fee_values = [(order.event.cached_default_tax_rule or tax_rule_zero, new_fee)] + elif tax_mode == "split": + fee_values = split_fee_for_taxes(positions, new_fee, order.event) + else: + fee_values = [(tax_rule_zero, new_fee)] + + try: + ia = order.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None + + for tax_rule, price in fee_values: + tax_rule = tax_rule or tax_rule_zero + tax = tax_rule.tax( + price, invoice_address=ia, base_price_is="gross" + ) + f = OrderFee( + fee_type=OrderFee.FEE_TYPE_CANCELLATION, + value=price, + order=order, + tax_rate=tax.rate, + tax_code=tax.code, + tax_value=tax.tax, + tax_rule=tax_rule, + ) + f.save() if cancellation_fee > order.total: raise OrderError(_('The cancellation fee cannot be higher than the total amount of this order.')) diff --git a/src/pretix/base/services/tax.py b/src/pretix/base/services/tax.py index 5d442c504d..e4eb37a107 100644 --- a/src/pretix/base/services/tax.py +++ b/src/pretix/base/services/tax.py @@ -22,6 +22,8 @@ import logging import os import re +from collections import defaultdict +from decimal import Decimal from xml.etree import ElementTree import requests @@ -32,7 +34,9 @@ from zeep import Client, Transport from zeep.cache import SqliteCache from zeep.exceptions import Fault -from pretix.base.models.tax import cc_to_vat_prefix, is_eu_country +from pretix.base.decimal import round_decimal +from pretix.base.models import CartPosition, Event, OrderFee +from pretix.base.models.tax import TaxRule, cc_to_vat_prefix, is_eu_country logger = logging.getLogger(__name__) error_messages = { @@ -229,3 +233,64 @@ def validate_vat_id(vat_id, country_code): return _validate_vat_id_NO(vat_id, country_code) raise VATIDTemporaryError(f'VAT ID should not be entered for country {country_code}') + + +def split_fee_for_taxes(positions: list, fee_value: Decimal, event: Event): + """ + Given a list of either OrderPosition, OrderFee or CartPosition objects and the total value + of a fee, this will return a list of [(tax_rule, fee_value)] tuples that distributes the + taxes over the same tax rules as the positions with a value representative to the value + the tax rule. + + Since the input fee_value is a gross value, we also split it by the gross percentages of + positions. This will lead to the same result as if we split the net value by net percentages, + but is easier to compute. + """ + d = defaultdict(lambda: Decimal("0.00")) + tax_rule_zero = TaxRule.zero() + trs = {} + for p in positions: + if isinstance(p, CartPosition): + tr = p.item.tax_rule + v = p.price + elif isinstance(p, OrderFee): + tr = p.tax_rule + v = p.value + else: + tr = p.tax_rule + v = p.price + if not tr: + tr = tax_rule_zero + # use tr.pk as key as tax_rule_zero is not hashable + d[tr.pk] += v + trs[tr.pk] = tr + + base_values = sorted([(trs[key], value) for key, value in d.items()], key=lambda t: t[0].rate) + sum_base = sum(value for key, value in base_values) + if sum_base: + fee_values = [ + (key, round_decimal(fee_value * value / sum_base, event.currency)) + for key, value in base_values + ] + sum_fee = sum(value for key, value in fee_values) + + # If there are rounding differences, we fix them up, but always leaning to the benefit of the tax + # authorities + if sum_fee > fee_value: + fee_values[0] = ( + fee_values[0][0], + fee_values[0][1] + (fee_value - sum_fee), + ) + elif sum_fee < fee_value: + fee_values[-1] = ( + fee_values[-1][0], + fee_values[-1][1] + (fee_value - sum_fee), + ) + elif len(d) == 1: + # Rare edge case: All positions are 0-valued, but have a common tax rate. Could happen e.g. with a discount + # that reduces all positions to 0, but not the shipping fees. Let's use that tax rate! + fee_values = [(list(trs.values())[0], fee_value)] + else: + # All positions are zero-valued, and we have no clear tax rate, so we use the default tax rate. + fee_values = [(event.cached_default_tax_rule or tax_rule_zero, fee_value)] + return fee_values diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index d9d93ea3c8..550d747ebd 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -66,7 +66,7 @@ from pretix.api.serializers.fields import ( ) from pretix.api.serializers.i18n import I18nURLField from pretix.base.forms import I18nMarkdownTextarea, I18nURLFormField -from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule +from pretix.base.models.tax import VAT_ID_COUNTRIES from pretix.base.reldate import ( RelativeDateField, RelativeDateTimeField, RelativeDateWrapper, SerializerRelativeDateField, SerializerRelativeDateTimeField, @@ -1027,9 +1027,47 @@ DEFAULTS = { widget=forms.CheckboxInput, ) }, - 'tax_rate_default': { - 'default': None, - 'type': TaxRule + 'tax_rule_payment': { + 'default': 'default', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=( + ('default', _('Use default tax rate')), + ('none', _('Charge no taxes')), + ), + ), + 'form_kwargs': dict( + label=_("Tax handling on payment fees"), + widget=forms.RadioSelect, + choices=( + ('default', _('Use default tax rate')), + ('none', _('Charge no taxes')), + ), + ) + }, + 'tax_rule_cancellation': { + 'default': 'none', + 'type': str, + 'form_class': forms.ChoiceField, + 'serializer_class': serializers.ChoiceField, + 'serializer_kwargs': dict( + choices=( + ('none', _('Charge no taxes')), + ('split', _('Use same taxes as order positions (split according to net prices)')), + ('default', _('Use default tax rate')), + ), + ), + 'form_kwargs': dict( + label=_("Tax handling on cancellation fees"), + widget=forms.RadioSelect, + choices=( + ('none', _('Charge no taxes')), + ('split', _('Use same taxes as order positions (split according to net prices)')), + ('default', _('Use default tax rate')), + ), + ) }, 'invoice_generate': { 'default': 'False', diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 38772fde72..ec4335d130 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -761,6 +761,7 @@ class CancelSettingsForm(SettingsForm): 'change_allow_user_addons', 'change_allow_user_if_checked_in', 'change_allow_attendee', + 'tax_rule_cancellation', ] def __init__(self, *args, **kwargs): @@ -783,14 +784,8 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm): 'payment_term_accept_late', 'payment_pending_hidden', 'payment_explanation', + 'tax_rule_payment', ] - tax_rate_default = forms.ModelChoiceField( - queryset=TaxRule.objects.none(), - label=_('Tax rule for payment fees'), - required=False, - help_text=_("The tax rule that applies for additional fees you configured for single payment methods. This " - "will set the tax rate and reverse charge rules, other settings of the tax rule are ignored.") - ) def clean_payment_term_days(self): value = self.cleaned_data.get('payment_term_days') @@ -804,10 +799,6 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm): raise ValidationError(_("This field is required.")) return value - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['tax_rate_default'].queryset = self.obj.tax_rules.all() - class ProviderForm(SettingsForm): """ diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 083e7f8b0e..029ff585c6 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -406,7 +406,6 @@ class ItemCreateForm(I18nModelForm): self.fields['tax_rule'].queryset = self.instance.event.tax_rules.all() change_decimal_field(self.fields['default_price'], self.instance.event.currency) - self.fields['tax_rule'].empty_label = _('No taxation') self.fields['copy_from'] = forms.ModelChoiceField( label=_("Copy product information"), queryset=self.event.items.all(), @@ -416,6 +415,8 @@ class ItemCreateForm(I18nModelForm): ) if self.event.tax_rules.exists(): self.fields['tax_rule'].required = True + else: + self.fields['tax_rule'].empty_label = _('No taxation') if not self.event.has_subevents: choices = [ diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index 77625386fe..737c6aca31 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -174,8 +174,7 @@ class CancelForm(forms.Form): label=_('Keep a cancellation fee of'), help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced ' 'to a cancellation fee. Payment and shipping fees will be canceled as well, so include them ' - 'in your cancellation fee if you want to keep them. Please always enter a gross value, ' - 'tax will be calculated automatically.'), + 'in your cancellation fee if you want to keep them.'), ) cancel_invoice = forms.BooleanField( label=_('Generate cancellation for invoice'), @@ -200,6 +199,19 @@ class CancelForm(forms.Form): self.fields['cancellation_fee'].max_value = self.instance.total if not self.instance.invoices.exists(): del self.fields['cancel_invoice'] + if self.instance.event.settings.tax_rule_cancellation == 'split': + self.fields['cancellation_fee'].help_text = str(self.fields['cancellation_fee'].help_text) + ' ' + _( + 'Please enter a gross amount. As per your event settings, the taxes will be split the same way as the ' + 'order positions.' + ) + elif self.instance.event.settings.tax_rule_cancellation == 'default': + self.fields['cancellation_fee'].help_text = str(self.fields['cancellation_fee'].help_text) + ' ' + _( + 'Please enter a gross amount. As per your event settings, the default tax rate will be charged.' + ) + elif self.instance.event.settings.tax_rule_cancellation == 'none': + self.fields['cancellation_fee'].help_text = str(self.fields['cancellation_fee'].help_text) + ' ' + _( + 'As per your event settings, no tax will be charged.' + ) def clean_cancellation_fee(self): val = self.cleaned_data['cancellation_fee'] or Decimal('0.00') diff --git a/src/pretix/control/templates/pretixcontrol/event/cancel.html b/src/pretix/control/templates/pretixcontrol/event/cancel.html index 7a206c7061..8ad8e6d813 100644 --- a/src/pretix/control/templates/pretixcontrol/event/cancel.html +++ b/src/pretix/control/templates/pretixcontrol/event/cancel.html @@ -26,6 +26,7 @@ {% bootstrap_field form.cancel_allow_user_paid_keep layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_keep_percentage layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_keep_fees layout="control" %} + {% bootstrap_field form.tax_rule_cancellation layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_until layout="control" %} {% bootstrap_field form.cancel_allow_user_paid_adjust_fees layout="control" %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index bf481901c4..c1746a37d2 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -79,7 +79,7 @@
{% trans "Advanced" %} {% bootstrap_form_errors form layout="control" %} - {% bootstrap_field form.tax_rate_default layout="control" %} + {% bootstrap_field form.tax_rule_payment layout="control" %} {% bootstrap_field form.payment_explanation layout="control" %}
diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_delete.html b/src/pretix/control/templates/pretixcontrol/event/tax_delete.html index 2f4cd0a5a0..db5d0bb31d 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tax_delete.html +++ b/src/pretix/control/templates/pretixcontrol/event/tax_delete.html @@ -9,11 +9,10 @@ {% if possible %}

{% blocktrans %}Are you sure you want to delete the tax rule {{ taxrule }}?{% endblocktrans %}

{% else %} -

{% blocktrans %}You cannot delete a tax rule that is in use for a product or has been in use for any existing orders.{% endblocktrans %}

+

{% blocktrans %}You cannot delete a tax rule that is in use for a product, has been in use for any existing orders, or is the default tax rule of the event.{% endblocktrans %}

{% endif %}
- + {% trans "Cancel" %} {% if possible %} diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_index.html b/src/pretix/control/templates/pretixcontrol/event/tax_index.html index ec15dc5256..19123adb1b 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tax_index.html +++ b/src/pretix/control/templates/pretixcontrol/event/tax_index.html @@ -24,6 +24,7 @@ {% trans "Name" %} + {% trans "Default" %} {% trans "Rate" %} @@ -36,6 +37,22 @@ {{ tr.internal_name|default:tr.name }} + + {% if tr.default %} + + + {% trans "Default" %} + + {% else %} +
+ {% csrf_token %} + +
+ {% endif %} + {% if tr.price_includes_tax %} {% blocktrans with rate=tr.rate%}incl. {{ rate }} %{% endblocktrans %} diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index c31878be8e..b7a0d0a753 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -288,6 +288,7 @@ urlpatterns = [ re_path(r'^settings/tax/(?P\d+)/$', event.TaxUpdate.as_view(), name='event.settings.tax.edit'), re_path(r'^settings/tax/add$', event.TaxCreate.as_view(), name='event.settings.tax.add'), re_path(r'^settings/tax/(?P\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'), + re_path(r'^settings/tax/(?P\d+)/default$', event.TaxDefault.as_view(), name='event.settings.tax.default'), re_path(r'^settings/widget$', event.WidgetSettings.as_view(), name='event.settings.widget'), re_path(r'^pdf/editor/webfonts.css', pdf.FontsCSSView.as_view(), name='pdf.css'), re_path(r'^pdf/editor/(?P[^/]+).pdf$', pdf.PdfView.as_view(), name='pdf.background'), diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 9ffb76eac0..a5d6fc408e 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -68,7 +68,7 @@ from django.utils.http import url_has_allowed_host_and_scheme from django.utils.safestring import mark_safe from django.utils.timezone import now from django.utils.translation import gettext, gettext_lazy as _, gettext_noop -from django.views.generic import FormView, ListView +from django.views.generic import DetailView, FormView, ListView from django.views.generic.base import TemplateView, View from django.views.generic.detail import SingleObjectMixin from i18nfield.strings import LazyI18nString @@ -1274,6 +1274,8 @@ class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView @transaction.atomic def form_valid(self, form): + if not self.request.event.tax_rules.exists(): + form.instance.default = True form.instance.event = self.request.event form.instance.custom_rules = json.dumps([ f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms @@ -1354,6 +1356,50 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView return super().form_invalid(form) +class TaxDefault(EventSettingsViewMixin, EventPermissionRequiredMixin, DetailView): + model = TaxRule + permission = 'can_change_event_settings' + + def get_object(self, queryset=None) -> TaxRule: + try: + return self.request.event.tax_rules.get( + id=self.kwargs['rule'] + ) + except TaxRule.DoesNotExist: + raise Http404(_("The requested tax rule does not exist.")) + + def get(self, request, *args, **kwargs): + return self.http_method_not_allowed(request, *args, **kwargs) + + @transaction.atomic + def post(self, request, *args, **kwargs): + messages.success(self.request, _('Your changes have been saved.')) + obj = self.get_object() + if not obj.default: + for tr in self.request.event.tax_rules.filter(default=True): + tr.log_action( + 'pretix.event.taxrule.changed', user=self.request.user, data={ + 'default': False, + } + ) + tr.default = False + tr.save(update_fields=['default']) + obj.log_action( + 'pretix.event.taxrule.changed', user=self.request.user, data={ + 'default': True, + } + ) + obj.default = True + obj.save(update_fields=['default']) + return redirect(self.get_success_url()) + + def get_success_url(self) -> str: + return reverse('control:event.settings.tax', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + class TaxDelete(EventSettingsViewMixin, EventPermissionRequiredMixin, CompatDeleteView): model = TaxRule template_name = 'pretixcontrol/event/tax_delete.html' diff --git a/src/pretix/control/views/main.py b/src/pretix/control/views/main.py index 1f1b24cafd..6501aea34e 100644 --- a/src/pretix/control/views/main.py +++ b/src/pretix/control/views/main.py @@ -181,8 +181,9 @@ class EventWizard(SafeSessionWizardView): initial['location'] = self.clone_from.location initial['timezone'] = self.clone_from.settings.timezone initial['locale'] = self.clone_from.settings.locale - if self.clone_from.settings.tax_rate_default: - initial['tax_rate'] = self.clone_from.settings.tax_rate_default.rate + tax_rule = self.clone_from.cached_default_tax_rule + if tax_rule: + initial['tax_rate'] = tax_rule.rate if 'organizer' in self.request.GET: if step == 'foundation': try: @@ -325,10 +326,17 @@ class EventWizard(SafeSessionWizardView): event.set_defaults() if basics_data['tax_rate'] is not None: - if not event.settings.tax_rate_default or event.settings.tax_rate_default.rate != basics_data['tax_rate']: - event.settings.tax_rate_default = event.tax_rules.create( + if self.clone_from: + default_tax_rule = self.clone_from.cached_default_tax_rule + elif copy_data and copy_data['copy_from_event']: + default_tax_rule = from_event.cached_default_tax_rule + else: + default_tax_rule = None + if not default_tax_rule or default_tax_rule.rate != basics_data['tax_rate']: + event.tax_rules.create( name=LazyI18nString.from_gettext(gettext('VAT')), - rate=basics_data['tax_rate'] + rate=basics_data['tax_rate'], + default=not default_tax_rule, ) event.settings.set('timezone', basics_data['timezone']) diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 672154f368..572c6ce00c 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -192,7 +192,7 @@ def subevent2(event2, meta_prop): @pytest.fixture @scopes_disabled() def taxrule(event): - return event.tax_rules.create(name="VAT", rate=19, code="S/standard") + return event.tax_rules.create(name="VAT", rate=19, code="S/standard", default=True) @pytest.fixture diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index adee24beaf..fb7548ef4e 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -50,7 +50,7 @@ def item2(event2): @pytest.fixture def taxrule(event): - return event.tax_rules.create(rate=Decimal('19.00'), code="S/standard") + return event.tax_rules.create(rate=Decimal('19.00'), code="S/standard", default=True) @pytest.fixture @@ -1351,7 +1351,7 @@ def test_order_mark_canceled_pending(token_client, organizer, event, order): @pytest.mark.django_db def test_order_mark_canceled_pending_fee_with_tax(token_client, organizer, event, order, taxrule): djmail.outbox = [] - event.settings.tax_rate_default = taxrule + event.settings.tax_rule_cancellation = "default" resp = token_client.post( '/api/v1/organizers/{}/events/{}/orders/{}/mark_canceled/'.format( organizer.slug, event.slug, order.code diff --git a/src/tests/api/test_taxrules.py b/src/tests/api/test_taxrules.py index a6d01dee3c..b342e09c3d 100644 --- a/src/tests/api/test_taxrules.py +++ b/src/tests/api/test_taxrules.py @@ -31,6 +31,7 @@ TEST_TAXRULE_RES = { 'keep_gross_if_rate_changes': False, 'name': {'en': 'VAT'}, 'rate': '19.00', + 'default': True, 'code': 'S/standard', 'price_includes_tax': True, 'eu_reverse_charge': False, @@ -80,6 +81,45 @@ def test_rule_create(token_client, organizer, event): assert str(rule.home_country) == "DE" +@pytest.mark.django_db +def test_rule_create_auto_default(token_client, organizer, event): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/taxrules/'.format(organizer.slug, event.slug), + { + "name": {"en": "VAT", "de": "MwSt"}, + "rate": "19.00", + "price_includes_tax": True, + "eu_reverse_charge": False, + "home_country": "DE", + }, + format='json' + ) + assert resp.status_code == 201 + rule = TaxRule.objects.get(pk=resp.data['id']) + assert rule.default + + +@pytest.mark.django_db +def test_rule_create_only_one_default(token_client, taxrule, organizer, event): + assert taxrule.default + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/taxrules/'.format(organizer.slug, event.slug), + { + "name": {"en": "VAT", "de": "MwSt"}, + "rate": "19.00", + "price_includes_tax": True, + "eu_reverse_charge": False, + "home_country": "DE", + "default": True, + }, + format='json' + ) + assert resp.status_code == 201 + + taxrule.refresh_from_db() + assert not taxrule.default + + @pytest.mark.django_db def test_rule_update(token_client, organizer, event, taxrule): resp = token_client.patch( diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index a98eca66d7..e2a919dcc3 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -2215,7 +2215,7 @@ class EventTest(TestCase): is_public=True, ) event1.meta_values.create(property=prop, value="DE") - tr7 = event1.tax_rules.create(rate=Decimal('7.00')) + tr7 = event1.tax_rules.create(rate=Decimal('7.00'), default=True) c1 = event1.categories.create(name='Tickets') c2 = event1.categories.create(name='Workshops') i1 = event1.items.create(name='Foo', default_price=Decimal('13.00'), tax_rule=tr7, @@ -2228,7 +2228,6 @@ class EventTest(TestCase): que1 = event1.questions.create(question="Age", type="N") que1.items.add(i1) event1.settings.foo_setting = 23 - event1.settings.tax_rate_default = tr7 cl1 = event1.checkin_lists.create( name="All", all_products=False, rules={ @@ -2271,7 +2270,7 @@ class EventTest(TestCase): assert que1new.type == que1.type assert que1new.items.get(pk=i1new.pk) assert event2.settings.foo_setting == '23' - assert event2.settings.tax_rate_default == trnew + assert event2.cached_default_tax_rule == trnew assert event2.checkin_lists.count() == 1 clnew = event2.checkin_lists.first() assert [i.pk for i in clnew.limit_products.all()] == [i1new.pk] diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index b705bc22ed..d30ed3336f 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -1117,7 +1117,7 @@ class OrderCancelTests(TestCase): self.order.save() self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5) with pytest.raises(OrderError): - cancel_order(self.order.pk, cancellation_fee=50) + cancel_order(self.order.pk, cancellation_fee=Decimal("50.00")) self.order.refresh_from_db() assert self.order.status == Order.STATUS_PAID assert self.order.total == 46 @@ -1131,7 +1131,7 @@ class OrderCancelTests(TestCase): self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5) self.op1.voucher = self.event.vouchers.create(item=self.ticket, redeemed=1) self.op1.save() - cancel_order(self.order.pk, cancellation_fee=2.5) + cancel_order(self.order.pk, cancellation_fee=Decimal("2.50")) self.order.refresh_from_db() assert self.order.status == Order.STATUS_PAID self.op1.refresh_from_db() @@ -1158,7 +1158,7 @@ class OrderCancelTests(TestCase): self.order.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=48.5) self.op1.voucher = self.event.vouchers.create(item=self.ticket, redeemed=1) self.op1.save() - cancel_order(self.order.pk, cancellation_fee=2.5) + cancel_order(self.order.pk, cancellation_fee=Decimal("2.50")) self.order.refresh_from_db() assert self.order.status == Order.STATUS_PAID self.op1.refresh_from_db() @@ -1172,7 +1172,7 @@ class OrderCancelTests(TestCase): state=OrderPayment.PAYMENT_STATE_CONFIRMED, provider='testdummy_partialrefund' ) - cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + cancel_order(self.order.pk, cancellation_fee=Decimal("2.00"), try_auto_refund=True) r = self.order.refunds.get() assert r.state == OrderRefund.REFUND_STATE_DONE assert r.amount == Decimal('44.00') @@ -1190,7 +1190,7 @@ class OrderCancelTests(TestCase): provider='giftcard', info='{"gift_card": %d}' % gc.pk ) - cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + cancel_order(self.order.pk, cancellation_fee=Decimal("2.00"), try_auto_refund=True) r = self.order.refunds.get() assert r.state == OrderRefund.REFUND_STATE_DONE assert r.amount == Decimal('44.00') @@ -1209,7 +1209,7 @@ class OrderCancelTests(TestCase): state=OrderPayment.PAYMENT_STATE_CONFIRMED, provider='testdummy_partialrefund' ) - cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + cancel_order(self.order.pk, cancellation_fee=Decimal("2.00"), try_auto_refund=True) r = self.order.refunds.get() assert r.state == OrderRefund.REFUND_STATE_DONE assert gc.value == Decimal('0.00') @@ -1224,7 +1224,7 @@ class OrderCancelTests(TestCase): provider='testdummy_partialrefund' ) with pytest.raises(OrderError): - cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + cancel_order(self.order.pk, cancellation_fee=Decimal("2.00"), try_auto_refund=True) assert gc.value == Decimal('20.00') @classscope(attr='o') @@ -1234,7 +1234,7 @@ class OrderCancelTests(TestCase): state=OrderPayment.PAYMENT_STATE_CONFIRMED, provider='testdummy_fullrefund' ) - cancel_order(self.order.pk, cancellation_fee=2, try_auto_refund=True) + cancel_order(self.order.pk, cancellation_fee=Decimal("2.00"), try_auto_refund=True) assert not self.order.refunds.exists() assert self.order.all_logentries().filter(action_type='pretix.event.order.refund.requested').exists() @@ -1258,7 +1258,7 @@ class OrderChangeManagerTests(TestCase): provider='banktransfer', state=OrderPayment.PAYMENT_STATE_CREATED, amount=self.order.total ) self.tr7 = self.event.tax_rules.create(rate=Decimal('7.00')) - self.tr19 = self.event.tax_rules.create(rate=Decimal('19.00')) + self.tr19 = self.event.tax_rules.create(rate=Decimal('19.00'), default=True) self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket', tax_rule=self.tr7, default_price=Decimal('23.00'), admission=True) self.ticket2 = Item.objects.create(event=self.event, name='Other ticket', tax_rule=self.tr7, @@ -1868,7 +1868,6 @@ class OrderChangeManagerTests(TestCase): @classscope(attr='o') def test_payment_fee_calculation(self): - self.event.settings.set('tax_rate_default', self.tr19.pk) prov = self.ocm._get_payment_provider() prov.settings.set('_fee_abs', Decimal('0.30')) self.ocm.change_price(self.op1, Decimal('24.00')) @@ -1882,7 +1881,6 @@ class OrderChangeManagerTests(TestCase): @classscope(attr='o') def test_pending_free_order_stays_pending(self): - self.event.settings.set('tax_rate_default', self.tr19.pk) self.ocm.change_price(self.op1, Decimal('0.00')) self.ocm.change_price(self.op2, Decimal('0.00')) self.ocm.commit() @@ -2270,7 +2268,6 @@ class OrderChangeManagerTests(TestCase): @classscope(attr='o') def test_recalculate_country_rate(self): - self.event.settings.set('tax_rate_default', self.tr19.pk) prov = self.ocm._get_payment_provider() prov.settings.set('_fee_abs', Decimal('0.30')) self.ocm._recalculate_total_and_payment_fee() @@ -2303,7 +2300,6 @@ class OrderChangeManagerTests(TestCase): @classscope(attr='o') def test_recalculate_country_rate_keep_gross(self): - self.event.settings.set('tax_rate_default', self.tr19.pk) prov = self.ocm._get_payment_provider() prov.settings.set('_fee_abs', Decimal('0.30')) self.ocm._recalculate_total_and_payment_fee() @@ -2334,7 +2330,6 @@ class OrderChangeManagerTests(TestCase): @classscope(attr='o') def test_recalculate_reverse_charge(self): - self.event.settings.set('tax_rate_default', self.tr19.pk) prov = self.ocm._get_payment_provider() prov.settings.set('_fee_abs', Decimal('0.30')) self.ocm._recalculate_total_and_payment_fee() @@ -2493,7 +2488,6 @@ class OrderChangeManagerTests(TestCase): @classscope(attr='o') def test_split_pending_payment_fees(self): # Set payment fees - self.event.settings.set('tax_rate_default', self.tr19.pk) prov = self.ocm._get_payment_provider() prov.settings.set('_fee_percent', Decimal('2.00')) prov.settings.set('_fee_abs', Decimal('1.00')) @@ -2697,7 +2691,6 @@ class OrderChangeManagerTests(TestCase): ia = self._enable_reverse_charge() # Set payment fees - self.event.settings.set('tax_rate_default', self.tr19.pk) prov = self.ocm._get_payment_provider() prov.settings.set('_fee_percent', Decimal('2.00')) prov.settings.set('_fee_reverse_calc', False) @@ -2791,7 +2784,6 @@ class OrderChangeManagerTests(TestCase): @classscope(attr='o') def test_split_paid_payment_fees(self): # Set payment fees - self.event.settings.set('tax_rate_default', self.tr19.pk) prov = self.ocm._get_payment_provider() prov.settings.set('_fee_percent', Decimal('2.00')) prov.settings.set('_fee_abs', Decimal('1.00')) diff --git a/src/tests/base/test_taxrules.py b/src/tests/base/test_taxrules.py index b14cc25886..6498ab9678 100644 --- a/src/tests/base/test_taxrules.py +++ b/src/tests/base/test_taxrules.py @@ -27,8 +27,11 @@ from django.utils.timezone import now from django_countries.fields import Country from django_scopes import scope -from pretix.base.models import Event, InvoiceAddress, Organizer, TaxRule +from pretix.base.models import ( + Event, InvoiceAddress, OrderFee, OrderPosition, Organizer, TaxRule, +) from pretix.base.models.tax import TaxedPrice +from pretix.base.services.tax import split_fee_for_taxes @pytest.fixture @@ -962,3 +965,39 @@ def test_allow_negative(event): price_includes_tax=True, ) assert tr.tax(Decimal('-100.00')).gross == Decimal("-100.00") + + +@pytest.mark.django_db +def test_split_fees(event): + tr19 = TaxRule(rate=Decimal("19.00"), pk=1) + tr7 = TaxRule(rate=Decimal("7.00"), pk=2) + item = event.items.create(name="Budget Ticket", default_price=23) + + op1 = OrderPosition(price=Decimal("11.90"), item=item) + op1._calculate_tax(tax_rule=tr19, invoice_address=InvoiceAddress()) + op2 = OrderPosition(price=Decimal("10.70"), item=item) + op2._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress()) + of1 = OrderFee(value=Decimal("5.00"), fee_type=OrderFee.FEE_TYPE_SHIPPING) + of1._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress()) + + # Example of a 10% service fee + assert split_fee_for_taxes([op1, op2], Decimal("2.26"), event) == [ + (tr7, Decimal("1.07")), + (tr19, Decimal("1.19")), + ] + + # Example of a full cancellation fee + assert split_fee_for_taxes([op1, op2], Decimal("22.60"), event) == [ + (tr7, Decimal("10.70")), + (tr19, Decimal("11.90")), + ] + assert split_fee_for_taxes([op1, op2, of1], Decimal("27.60"), event) == [ + (tr7, Decimal("15.70")), + (tr19, Decimal("11.90")), + ] + + # Example that rounding always is done with benefit to the highest tax rate + assert split_fee_for_taxes([op1, op2], Decimal("0.03"), event) == [ + (tr7, Decimal("0.01")), + (tr19, Decimal("0.02")), + ] diff --git a/src/tests/control/test_events.py b/src/tests/control/test_events.py index 93bc872720..e7c2e4011f 100644 --- a/src/tests/control/test_events.py +++ b/src/tests/control/test_events.py @@ -443,19 +443,17 @@ class EventsTest(SoupTest): assert self.event1.settings.get('payment_banktransfer__fee_abs', as_type=Decimal) == Decimal('12.23') def test_payment_settings(self): - tr19 = self.event1.tax_rules.create(rate=Decimal('19.00')) self.get_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug)) self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { 'payment_term_days': '2', 'payment_term_minutes': '30', 'payment_term_mode': 'days', - 'tax_rate_default': tr19.pk, + 'tax_rule_payment': 'default', }) self.event1.settings.flush() assert self.event1.settings.get('payment_term_days', as_type=int) == 2 def test_payment_settings_last_date_payment_after_presale_end(self): - tr19 = self.event1.tax_rules.create(rate=Decimal('19.00')) self.event1.presale_end = now() self.event1.save(update_fields=['presale_end']) doc = self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { @@ -464,15 +462,13 @@ class EventsTest(SoupTest): 'payment_term_last_1': (self.event1.presale_end - datetime.timedelta(1)).strftime('%Y-%m-%d'), 'payment_term_last_2': '0', 'payment_term_last_3': 'date_from', - 'tax_rate_default': tr19.pk, + 'tax_rule_payment': 'default', }) assert doc.select('.alert-danger') self.event1.presale_end = None self.event1.save(update_fields=['presale_end']) def test_payment_settings_relative_date_payment_after_presale_end(self): - with scopes_disabled(): - tr19 = self.event1.tax_rules.create(rate=Decimal('19.00')) self.event1.presale_end = self.event1.date_from - datetime.timedelta(days=5) self.event1.save(update_fields=['presale_end']) doc = self.post_doc('/control/event/%s/%s/settings/payment' % (self.orga1.slug, self.event1.slug), { @@ -481,7 +477,7 @@ class EventsTest(SoupTest): 'payment_term_last_1': '', 'payment_term_last_2': '10', 'payment_term_last_3': 'date_from', - 'tax_rate_default': tr19.pk, + 'tax_rule_payment': 'default', }) assert doc.select('.alert-danger') self.event1.presale_end = None @@ -912,7 +908,7 @@ class EventsTest(SoupTest): def test_create_event_copy_success(self): with scopes_disabled(): tr = self.event1.tax_rules.create( - rate=19, name="VAT" + rate=19, name="VAT", default=True ) q1 = self.event1.quotas.create( name='Foo', @@ -923,7 +919,6 @@ class EventsTest(SoupTest): category=None, default_price=23, tax_rule=tr, admission=True, hidden_if_available=q1 ) - self.event1.settings.tax_rate_default = tr doc = self.get_doc('/control/events/add') doc = self.post_doc('/control/events/add', { @@ -990,14 +985,13 @@ class EventsTest(SoupTest): def test_create_event_clone_success(self): with scopes_disabled(): tr = self.event1.tax_rules.create( - rate=19, name="VAT" + rate=19, name="VAT", default=True ) self.event1.items.create( name='Early-bird ticket', category=None, default_price=23, tax_rule=tr, admission=True ) - self.event1.settings.tax_rate_default = tr doc = self.get_doc('/control/events/add?clone=' + str(self.event1.pk)) tabletext = doc.select("form")[0].text self.assertIn("CCC", tabletext) diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index b598e59a52..aa6888ac69 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -439,8 +439,37 @@ def test_order_cancel_paid_keep_fee(client, env): o.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=o.total) o.status = Order.STATUS_PAID o.save() - tr7 = o.event.tax_rules.create(rate=Decimal('7.00')) - o.event.settings.tax_rate_default = tr7 + o.event.tax_rules.create(rate=Decimal('7.00'), default=True) + client.login(email='dummy@dummy.dummy', password='dummy') + client.get('/control/event/dummy/dummy/orders/FOO/transition?status=c') + client.post('/control/event/dummy/dummy/orders/FOO/transition', { + 'status': 'c', + 'cancellation_fee': '6.00' + }) + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + assert not o.positions.exists() + assert o.all_positions.exists() + f = o.fees.get() + assert f.fee_type == OrderFee.FEE_TYPE_CANCELLATION + assert f.value == Decimal('6.00') + assert f.tax_value == Decimal('0.00') + assert f.tax_rate == Decimal('0.00') + assert f.tax_rule is None + assert o.status == Order.STATUS_PAID + assert o.total == Decimal('6.00') + assert o.pending_sum == Decimal('-8.00') + + +@pytest.mark.django_db +def test_order_cancel_paid_keep_fee_taxed(client, env): + env[0].settings.tax_rule_cancellation = "default" + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + o.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=o.total) + o.status = Order.STATUS_PAID + o.save() + tr7 = o.event.tax_rules.create(rate=Decimal('7.00'), default=True) client.login(email='dummy@dummy.dummy', password='dummy') client.get('/control/event/dummy/dummy/orders/FOO/transition?status=c') client.post('/control/event/dummy/dummy/orders/FOO/transition', { @@ -462,6 +491,50 @@ def test_order_cancel_paid_keep_fee(client, env): assert o.pending_sum == Decimal('-8.00') +@pytest.mark.django_db +def test_order_cancel_paid_keep_fee_tax_split(client, env): + env[0].settings.tax_rule_cancellation = "split" + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + o.payments.create(state=OrderPayment.PAYMENT_STATE_CONFIRMED, amount=o.total) + o.status = Order.STATUS_PAID + o.save() + tr7 = o.event.tax_rules.create(rate=Decimal('7.00'), default=False) + tr19 = o.event.tax_rules.create(rate=Decimal('19.00'), default=True) + op1 = o.positions.first() + op1._calculate_tax(tax_rule=tr7) + op1.save() + op2 = o.all_positions.last() + op2.canceled = False + op2._calculate_tax(tax_rule=tr19) + op2.save() + client.login(email='dummy@dummy.dummy', password='dummy') + client.get('/control/event/dummy/dummy/orders/FOO/transition?status=c') + client.post('/control/event/dummy/dummy/orders/FOO/transition', { + 'status': 'c', + 'cancellation_fee': '6.00' + }) + with scopes_disabled(): + o = Order.objects.get(id=env[2].id) + assert not o.positions.exists() + assert o.all_positions.exists() + f = o.fees.order_by("-tax_rate") + assert len(f) == 2 + assert f[0].fee_type == OrderFee.FEE_TYPE_CANCELLATION + assert f[0].value == Decimal('3.00') + assert f[0].tax_value == Decimal('0.48') + assert f[0].tax_rate == Decimal('19') + assert f[0].tax_rule == tr19 + assert f[1].fee_type == OrderFee.FEE_TYPE_CANCELLATION + assert f[1].value == Decimal('3.00') + assert f[1].tax_value == Decimal('0.20') + assert f[1].tax_rate == Decimal('7') + assert f[1].tax_rule == tr7 + assert o.status == Order.STATUS_PAID + assert o.total == Decimal('6.00') + assert o.pending_sum == Decimal('-8.00') + + @pytest.mark.django_db def test_order_cancel_pending_keep_fee(client, env): with scopes_disabled(): diff --git a/src/tests/control/test_permissions.py b/src/tests/control/test_permissions.py index cc7b373df9..5a846d4cc8 100644 --- a/src/tests/control/test_permissions.py +++ b/src/tests/control/test_permissions.py @@ -104,6 +104,7 @@ event_urls = [ "settings/tax/add", "settings/tax/1/", "settings/tax/1/delete", + "settings/tax/1/default", "items/", "items/add", "items/1/", @@ -318,6 +319,7 @@ event_permission_urls = [ ("can_change_event_settings", "settings/tax/1/", 404, HTTP_GET), ("can_change_event_settings", "settings/tax/add", 200, HTTP_GET), ("can_change_event_settings", "settings/tax/1/delete", 404, HTTP_GET), + ("can_change_event_settings", "settings/tax/1/default", 404, HTTP_POST), ("can_change_event_settings", "comment/", 405, HTTP_GET), # Lists are currently not access-controlled # ("can_change_items", "items/", 200), diff --git a/src/tests/control/test_taxrates.py b/src/tests/control/test_taxrates.py index 49a1905015..78cc1953e1 100644 --- a/src/tests/control/test_taxrates.py +++ b/src/tests/control/test_taxrates.py @@ -56,9 +56,22 @@ class TaxRateFormTest(SoupTest): assert doc.select(".alert-success") self.assertIn("VAT", doc.select("#page-wrapper table")[0].text) with scopes_disabled(): - assert self.event1.tax_rules.get( + tr = self.event1.tax_rules.get( rate=19, price_includes_tax=True, eu_reverse_charge=False ) + assert tr.default + + def test_set_default(self): + with scopes_disabled(): + tr = self.event1.tax_rules.create(rate=19, name="VAT") + tr2 = self.event1.tax_rules.create(rate=7, name="VAT", default=True) + doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/default' % (self.orga1.slug, self.event1.slug, tr.id), + {}) + assert doc.select(".alert-success") + tr.refresh_from_db() + assert tr.default + tr2.refresh_from_db() + assert not tr2.default def test_update(self): with scopes_disabled(): @@ -98,8 +111,8 @@ class TaxRateFormTest(SoupTest): def test_delete_default_rule(self): with scopes_disabled(): - tr = self.event1.tax_rules.create(rate=19, name="VAT") - self.event1.settings.tax_rate_default = tr + tr = self.event1.tax_rules.create(rate=19, name="VAT", default=True) + self.event1.tax_rules.create(rate=7, name="V2") doc = self.get_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id)) form_data = extract_form_fields(doc.select('.container-fluid form')[0]) doc = self.post_doc('/control/event/%s/%s/settings/tax/%s/delete' % (self.orga1.slug, self.event1.slug, tr.id), diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 1522f53aa4..978eac9c4d 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -483,7 +483,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): assert cr1.price == Decimal('23.00') def test_custom_tax_rules_blocked_on_fee(self): - self.tr7 = self.event.tax_rules.create(rate=7) + self.tr7 = self.event.tax_rules.create(rate=7, default=True) self.tr7.custom_rules = json.dumps([ {'country': 'AT', 'address_type': 'business_vat_id', 'action': 'reverse'}, {'country': 'ZZ', 'address_type': '', 'action': 'block'}, @@ -492,7 +492,6 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): self.event.settings.set('payment_banktransfer__enabled', True) self.event.settings.set('payment_banktransfer__fee_percent', 20) self.event.settings.set('payment_banktransfer__fee_reverse_calc', False) - self.event.settings.set('tax_rate_default', self.tr7) self.event.settings.invoice_address_vatid = True with scopes_disabled():