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 <weller@rami.io>

* Update src/tests/api/test_taxrules.py

Co-authored-by: luelista <weller@rami.io>

* Review note

* Update src/pretix/base/models/tax.py

Co-authored-by: luelista <weller@rami.io>

* Flip API behaviour for default

* Fix failing tests

* Fix failing test

* Split migration

---------

Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
Raphael Michel
2025-06-30 16:47:09 +02:00
committed by GitHub
parent 090358833d
commit 14ed6982a5
34 changed files with 615 additions and 104 deletions

View File

@@ -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):

View File

@@ -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

View File

@@ -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",
),
),
]

View File

@@ -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,
),
]

View File

@@ -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:
"""

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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.'))

View File

@@ -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

View File

@@ -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',

View File

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

View File

@@ -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 = [

View File

@@ -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')

View File

@@ -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" %}
<div data-display-dependency="#id_cancel_allow_user_paid_adjust_fees">

View File

@@ -79,7 +79,7 @@
<fieldset>
<legend>{% trans "Advanced" %}</legend>
{% 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" %}
</fieldset>
</div>

View File

@@ -9,11 +9,10 @@
{% if possible %}
<p>{% blocktrans %}Are you sure you want to delete the tax rule <strong>{{ taxrule }}</strong>?{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans %}You cannot delete a tax rule that is in use for a product or has been in use for any existing orders.{% endblocktrans %}</p>
<p>{% 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 %}</p>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:event.settings.tax" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn
btn-default btn-cancel">
<a href="{% url "control:event.settings.tax" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if possible %}

View File

@@ -24,6 +24,7 @@
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th>{% trans "Rate" %}</th>
<th class="action-col-2"></th>
</tr>
@@ -36,6 +37,22 @@
{{ tr.internal_name|default:tr.name }}
</a></strong>
</td>
<td>
{% if tr.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% else %}
<form class="form-inline" method="post"
action="{% url "control:event.settings.tax.default" organizer=request.event.organizer.slug event=request.event.slug rule=tr.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td>
{% if tr.price_includes_tax %}
{% blocktrans with rate=tr.rate%}incl. {{ rate }} %{% endblocktrans %}

View File

@@ -288,6 +288,7 @@ urlpatterns = [
re_path(r'^settings/tax/(?P<rule>\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<rule>\d+)/delete$', event.TaxDelete.as_view(), name='event.settings.tax.delete'),
re_path(r'^settings/tax/(?P<rule>\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<filename>[^/]+).pdf$', pdf.PdfView.as_view(), name='pdf.background'),

View File

@@ -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'

View File

@@ -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'])