diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst
index 2947015f1..a8e6b7239 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 616122179..f7351a805 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 659d41e3c..19b2fb7d2 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 000000000..bfda92921
--- /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 000000000..c3a66f365
--- /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 97e0c1ec5..7f28f2e97 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 9db278422..9a8d5ea7c 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 af4c5ed48..1058934cd 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 1211412e7..3f14c91dc 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 5212b1307..3b256bd62 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 32d14da3e..200b8a9f2 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 5d442c504..e4eb37a10 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 d9d93ea3c..550d747eb 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 38772fde7..ec4335d13 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 083e7f8b0..029ff585c 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 77625386f..737c6aca3 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 7a206c706..8ad8e6d81 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 bf481901c..c1746a37d 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 2f4cd0a5a..db5d0bb31 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 ec15dc525..19123adb1 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 %}
+
+ {% 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 c31878be8..b7a0d0a75 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 9ffb76eac..a5d6fc408 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 1f1b24caf..6501aea34 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 672154f36..572c6ce00 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 adee24bea..fb7548ef4 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 a6d01dee3..b342e09c3 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 a98eca66d..e2a919dcc 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 b705bc22e..d30ed3336 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 b14cc2588..6498ab967 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 93bc87272..e7c2e4011 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 b598e59a5..aa6888ac6 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 cc7b373df..5a846d4cc 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 49a190501..78cc1953e 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 1522f53aa..978eac9c4 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():