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

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