Add codification of tax rates (#4372)

* draft

* .

* Rebase migration

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

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

* Test, isort, flake, migration rebase

* carry data & API

* Fix failing tests

* docs fixes

* Improve validation

* Tests

* More fixes

---------

Co-authored-by: Mira <weller@rami.io>
This commit is contained in:
Raphael Michel
2024-12-13 12:04:38 +01:00
committed by GitHub
parent a4385c8b6e
commit 53f129d5d3
36 changed files with 818 additions and 124 deletions

View File

@@ -63,6 +63,7 @@ from pretix.base.forms import (
)
from pretix.base.models import Event, Organizer, TaxRule, Team
from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent
from pretix.base.models.tax import TAX_CODE_LISTS
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.services.placeholders import FormPlaceholderMixin
from pretix.base.settings import (
@@ -1504,6 +1505,11 @@ class TaxRuleLineForm(I18nForm):
('require_approval', _('Order requires approval')),
],
)
code = forms.ChoiceField(
label=_("Tax code"),
choices=[("", _("Default tax code")), *TAX_CODE_LISTS],
required=False,
)
rate = forms.DecimalField(
label=_('Deviating tax rate'),
max_digits=10, decimal_places=2,
@@ -1518,6 +1524,43 @@ class TaxRuleLineForm(I18nForm):
})
)
def __init__(self, *args, **kwargs):
self.parent_form = kwargs.pop("parent_form")
super().__init__(*args, **kwargs)
def clean(self):
d = super().clean()
parent_code = self.parent_form.cleaned_data.get("code")
parent_rate = self.parent_form.cleaned_data.get("rate")
code = d.get("code") or parent_code
rate = d.get("rate")
if rate is None:
rate = parent_rate
if d.get("action") in ("reverse", "no", "block") and d.get("rate"):
raise ValidationError(_("A combination of this calculation mode with a non-zero tax rate does not make sense."))
if d.get("action") == "reverse" and d.get("code") and code != "AE":
# Reverse charge but code is not reverse charge -- this is the one case we ignore if the "default code"
# is used because it is the one scenario we can auto-fix
raise ValidationError(_("This combination of calculation mode and tax code does not make sense."))
if d.get("action") == "no" and code and code.split("/")[0] in ("S", "AE", "L", "M", "B"):
# No VAT but code indicates VAT
raise ValidationError(_("This combination of calculation mode and tax code does not make sense."))
if d.get("action") == "vat" and code and rate != Decimal("0.00") and code.split("/")[0] in ("O", "E", "Z", "G", "K", "AE"):
# VAT, but code indicates exempt
raise ValidationError(_("A combination of this tax code with a non-zero tax rate does not make sense."))
if d.get("action") == "vat" and code and rate == Decimal("0.00") and code.split("/")[0] in ("S", "L", "M", "B"):
# no VAT, but code indicates non-exempt
raise ValidationError(_("A combination of this tax code with a zero tax rate does not make sense."))
return d
class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet):
# compatibility shim for django-i18nfield library
@@ -1529,8 +1572,16 @@ class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet):
super().__init__(*args, **kwargs)
class BaseTaxRuleLineFormSet(I18nBaseFormSet):
def __init__(self, *args, **kwargs):
self.parent_form = kwargs.pop('parent_form')
super().__init__(*args, **kwargs)
self.form_kwargs['parent_form'] = self.parent_form
TaxRuleLineFormSet = formset_factory(
TaxRuleLineForm, formset=I18nBaseFormSet,
TaxRuleLineForm, formset=BaseTaxRuleLineFormSet,
can_order=True, can_delete=True, extra=0
)
@@ -1538,7 +1589,16 @@ TaxRuleLineFormSet = formset_factory(
class TaxRuleForm(I18nModelForm):
class Meta:
model = TaxRule
fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes']
fields = [
'name',
'rate',
'price_includes_tax',
'code',
'eu_reverse_charge',
'home_country',
'internal_name',
'keep_gross_if_rate_changes'
]
class WidgetCodeForm(forms.Form):

View File

@@ -26,6 +26,7 @@
{% bootstrap_field form.name layout="control" %}
{% bootstrap_field form.internal_name layout="control" %}
{% bootstrap_field form.rate addon_after="%" layout="control" %}
{% bootstrap_field form.code layout="control" %}
{% bootstrap_field form.price_includes_tax layout="control" %}
</fieldset>
<fieldset>
@@ -52,6 +53,18 @@
{% trans "All of these rules will only apply if an invoice address is set." %}
</div>
<div class="row">
<div class="col-sm-6 col-md-3 col-lg-3">
<strong>{% trans "Condition" %}</strong>
</div>
<div class="col-sm-6 col-md-3 col-lg-3">
<strong>{% trans "Calculation" %}</strong>
</div>
<div class="col-sm-6 col-md-3 col-lg-4 col-sm-offset-6 col-md-offset-0">
<strong>{% trans "Reason" %}</strong>
</div>
</div>
<div class="formset tax-rules-formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
@@ -65,14 +78,17 @@
</div>
<div class="col-sm-6 col-md-3 col-lg-3">
{% bootstrap_field formset.empty_form.country layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-4">
{% bootstrap_field formset.empty_form.address_type layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-3">
{% bootstrap_field formset.empty_form.action layout='inline' form_group_class="" %}
{% bootstrap_field formset.empty_form.rate layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-2 text-right flip">
<div class="col-sm-6 col-md-3 col-lg-4 col-sm-offset-6 col-md-offset-0">
{% bootstrap_field formset.empty_form.code layout='inline' form_group_class="" %}
{% bootstrap_field formset.empty_form.invoice_text layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-2 col-sm-offset-6 col-md-offset-0 text-right flip">
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
@@ -80,12 +96,6 @@
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
<div class="col-sm-6 col-md-3 col-lg-4 col-md-offset-3">
{% bootstrap_field formset.empty_form.invoice_text layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-3">
{% bootstrap_field formset.empty_form.rate layout='inline' form_group_class="" %}
</div>
</div>
{% endescapescript %}
</script>
@@ -100,14 +110,17 @@
</div>
<div class="col-sm-6 col-md-3 col-lg-3">
{% bootstrap_field form.country layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-4">
{% bootstrap_field form.address_type layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-3">
{% bootstrap_field form.action layout='inline' form_group_class="" %}
{% bootstrap_field form.rate layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-2 text-right flip">
<div class="col-sm-6 col-md-3 col-lg-4 col-sm-offset-6 col-md-offset-0">
{% bootstrap_field form.code layout='inline' form_group_class="" %}
{% bootstrap_field form.invoice_text layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-2 col-sm-offset-6 col-md-offset-0 text-right flip">
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
@@ -115,12 +128,6 @@
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
<div class="col-sm-6 col-md-3 col-lg-4 col-md-offset-3">
{% bootstrap_field form.invoice_text layout='inline' form_group_class="" %}
</div>
<div class="col-sm-6 col-md-3 col-lg-3">
{% bootstrap_field form.rate layout='inline' form_group_class="" %}
</div>
</div>
{% endfor %}
</div>

View File

@@ -202,6 +202,12 @@
+ {{ position.tax_rate }}%)
</small>
{% endif %}
{% if position.tax_code %}
<br>
<small>
{{ position.get_tax_code_display }}
</small>
{% endif %}
</div>
<div class="col-sm-4 field-container">
{% bootstrap_field position.form.price addon_after=request.event.currency layout='inline' %}
@@ -420,6 +426,12 @@
+ {{ fee.tax_rate }}%)
</small>
{% endif %}
{% if fee.tax_code %}
<br>
<small>
{{ fee.get_tax_code_display }}
</small>
{% endif %}
</div>
<div class="col-sm-4 field-container">
{% bootstrap_field fee.form.value addon_after=request.event.currency layout='inline' %}

View File

@@ -19,6 +19,7 @@
<th>{% trans "Date" %}</th>
<th>{% trans "Product" %}</th>
<th class="text-right flip">{% trans "Tax rate" %}</th>
<th>{% trans "Tax code" %}</th>
<th class="text-right flip">{% trans "Quantity" %}</th>
<th class="text-right flip">{% trans "Single price" %}</th>
<th class="text-right flip">{% trans "Total tax value" %}</th>
@@ -52,6 +53,7 @@
{% endif %}
</td>
<td class="text-right flip">{{ t.tax_rate }} %</td>
<td>{{ t.get_tax_code_display }}</td>
<td class="text-right flip">{{ t.count }} &times;</td>
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
<td class="text-right flip">{{ t.full_tax_value|money:request.event.currency }}</td>
@@ -64,8 +66,8 @@
<td>
<strong>{% trans "Sum" %}</strong>
</td>
<td>
</td>
<td></td>
<td></td>
<td></td>
<td class="text-right flip">
<strong>

View File

@@ -1197,17 +1197,22 @@ class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView
def post(self, request, *args, **kwargs):
self.object = None
form = self.get_form()
form = self.form
if form.is_valid() and self.formset.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
@cached_property
def form(self):
return self.get_form()
@cached_property
def formset(self):
return TaxRuleLineFormSet(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
parent_form=self.form,
)
def get_context_data(self, **kwargs):
@@ -1248,17 +1253,22 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView
def post(self, request, *args, **kwargs):
self.object = self.get_object(self.get_queryset())
form = self.get_form()
form = self.form
if form.is_valid() and self.formset.is_valid():
return self.form_valid(form)
else:
return self.form_invalid(form)
@cached_property
def form(self):
return self.get_form()
@cached_property
def formset(self):
return TaxRuleLineFormSet(
data=self.request.POST if self.request.method == "POST" else None,
event=self.request.event,
parent_form=self.form,
initial=json.loads(self.object.custom_rules) if self.object.custom_rules else []
)