diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index cd14b0c37b..fb94ed0229 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -5,8 +5,9 @@ from django.core.exceptions import ValidationError from django.db import models from django.utils.formats import localize from django.utils.timezone import get_current_timezone, now -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _, pgettext from i18nfield.fields import I18nCharField +from i18nfield.strings import LazyI18nString from pretix.base.decimal import round_decimal from pretix.base.models.base import LoggedModel @@ -268,6 +269,25 @@ class TaxRule(LoggedModel): return r return {'action': 'vat'} + def invoice_text(self, invoice_address): + if self._custom_rules: + rule = self.get_matching_rule(invoice_address) + t = rule.get('invoice_text', {}) + if t and any(l for l in t.values()): + return str(LazyI18nString(t)) + if self.is_reverse_charge(invoice_address): + if is_eu_country(invoice_address.country): + return pgettext( + "invoice", + "Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability " + "rests with the service recipient." + ) + else: + return pgettext( + "invoice", + "VAT liability rests with the service recipient." + ) + def is_reverse_charge(self, invoice_address): if self._custom_rules: rule = self.get_matching_rule(invoice_address) diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 1de867a60a..69c8ec4bb4 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -24,7 +24,7 @@ from pretix.base.i18n import language from pretix.base.models import ( Invoice, InvoiceAddress, InvoiceLine, Order, OrderFee, ) -from pretix.base.models.tax import EU_CURRENCIES, is_eu_country +from pretix.base.models.tax import EU_CURRENCIES from pretix.base.services.tasks import TransactionAwareTask from pretix.base.settings import GlobalSettingsObject from pretix.base.signals import invoice_line_text, periodic_task @@ -142,6 +142,8 @@ def build_invoice(invoice: Invoice) -> Invoice: reverse_charge = False positions.sort(key=lambda p: p.sort_key) + + tax_texts = [] for i, p in enumerate(positions): if not invoice.event.settings.invoice_include_free and p.price == Decimal('0.00') and not p.addon_c: continue @@ -178,22 +180,10 @@ def build_invoice(invoice: Invoice) -> Invoice: if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value: reverse_charge = True - if reverse_charge: - if invoice.additional_text: - invoice.additional_text += "

" - if is_eu_country(invoice.invoice_to_country): - invoice.additional_text += pgettext( - "invoice", - "Reverse Charge: According to Article 194, 196 of Council Directive 2006/112/EEC, VAT liability " - "rests with the service recipient." - ) - else: - invoice.additional_text += pgettext( - "invoice", - "VAT liability rests with the service recipient." - ) - invoice.reverse_charge = True - invoice.save() + if p.tax_rule: + tax_text = p.tax_rule.invoice_text(ia) + if tax_text and tax_text not in tax_texts: + tax_texts.append(tax_text) offset = len(positions) for i, fee in enumerate(invoice.order.fees.all()): @@ -213,6 +203,20 @@ def build_invoice(invoice: Invoice) -> Invoice: tax_name=fee.tax_rule.name if fee.tax_rule else '' ) + if fee.tax_rule and fee.tax_rule.is_reverse_charge(ia) and fee.value and not fee.tax_value: + reverse_charge = True + + if fee.tax_rule: + tax_text = fee.tax_rule.invoice_text(ia) + if tax_text and tax_text not in tax_texts: + tax_texts.append(tax_text) + + if tax_texts: + invoice.additional_text += "

" + invoice.additional_text += "
".join(tax_texts) + invoice.reverse_charge = reverse_charge + invoice.save() + return invoice diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index f51efd3176..c630cdfe7d 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -19,7 +19,9 @@ from pytz import common_timezones, timezone from pretix.base.channels import get_all_sales_channels from pretix.base.email import get_available_placeholders -from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm +from pretix.base.forms import ( + I18nModelForm, PlaceholderValidator, SettingsForm, +) from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models.event import EventMetaValue, SubEvent from pretix.base.reldate import RelativeDateField, RelativeDateTimeField @@ -1099,7 +1101,7 @@ class CountriesAndEU(CachedCountries): cache_subkey = 'with_any_or_eu' -class TaxRuleLineForm(forms.Form): +class TaxRuleLineForm(I18nForm): country = LazyTypedChoiceField( choices=CountriesAndEU(), required=False @@ -1126,10 +1128,25 @@ class TaxRuleLineForm(forms.Form): max_digits=10, decimal_places=2, required=False ) + invoice_text = I18nFormField( + label=_('Text on invoice'), + required=False, + widget=I18nTextInput + ) + + +class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet): + # compatibility shim for django-i18nfield library + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event', None) + if self.event: + kwargs['locales'] = self.event.settings.get('locales') + super().__init__(*args, **kwargs) TaxRuleLineFormSet = formset_factory( - TaxRuleLineForm, + TaxRuleLineForm, formset=I18nBaseFormSet, can_order=True, can_delete=True, extra=0 ) diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html index 98651dc67e..3864ce4d85 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html +++ b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html @@ -61,10 +61,10 @@ {% bootstrap_field formset.empty_form.DELETE form_group_class="" layout="inline" %} {% bootstrap_field formset.empty_form.ORDER form_group_class="" layout="inline" %} -
+
{% bootstrap_field formset.empty_form.country layout='inline' form_group_class="" %}
-
+
{% bootstrap_field formset.empty_form.address_type layout='inline' form_group_class="" %}
@@ -78,7 +78,10 @@
-
+
+ {% bootstrap_field formset.empty_form.invoice_text layout='inline' form_group_class="" %} +
+
{% bootstrap_field formset.empty_form.rate layout='inline' form_group_class="" %}
@@ -93,10 +96,10 @@ {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} {% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
-
+
{% bootstrap_field form.country layout='inline' form_group_class="" %}
-
+
{% bootstrap_field form.address_type layout='inline' form_group_class="" %}
@@ -110,7 +113,10 @@
-
+
+ {% bootstrap_field form.invoice_text layout='inline' form_group_class="" %} +
+
{% bootstrap_field form.rate layout='inline' form_group_class="" %}
diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index fa87e3c7e4..5a7b1fbed4 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -10,7 +10,6 @@ from django.conf import settings from django.contrib import messages from django.contrib.contenttypes.models import ContentType from django.core.files import File -from django.core.serializers.json import DjangoJSONEncoder from django.db import transaction from django.db.models import ProtectedError from django.forms import inlineformset_factory @@ -28,6 +27,7 @@ from django.views.generic import DeleteView, FormView, ListView from django.views.generic.base import TemplateView, View from django.views.generic.detail import SingleObjectMixin from i18nfield.strings import LazyI18nString +from i18nfield.utils import I18nJSONEncoder from pytz import timezone from pretix.base.channels import get_all_sales_channels @@ -1093,6 +1093,7 @@ class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView def formset(self): return TaxRuleLineFormSet( data=self.request.POST if self.request.method == "POST" else None, + event=self.request.event, ) def get_context_data(self, **kwargs): @@ -1105,7 +1106,7 @@ class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView 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 - ], cls=DjangoJSONEncoder) + ], cls=I18nJSONEncoder) messages.success(self.request, _('The new tax rule has been created.')) ret = super().form_valid(form) form.instance.log_action('pretix.event.taxrule.added', user=self.request.user, data=dict(form.cleaned_data)) @@ -1143,6 +1144,7 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView def formset(self): return TaxRuleLineFormSet( data=self.request.POST if self.request.method == "POST" else None, + event=self.request.event, initial=json.loads(self.object.custom_rules) if self.object.custom_rules else [] ) @@ -1156,7 +1158,7 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView messages.success(self.request, _('Your changes have been saved.')) form.instance.custom_rules = json.dumps([ f.cleaned_data for f in self.formset.ordered_forms if f not in self.formset.deleted_forms - ], cls=DjangoJSONEncoder) + ], cls=I18nJSONEncoder) if form.has_changed(): self.object.log_action( 'pretix.event.taxrule.changed', user=self.request.user, data={ diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index c5bc0a628f..c0c88f838c 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -575,9 +575,7 @@ table td > .checkbox input[type="checkbox"] { margin-right: -15px; } .tax-rule-line { - padding-bottom: 5px; padding-top: 5px; - border-bottom: 1px solid $input-border; margin: 0; & > div { padding-top: 5px; @@ -586,4 +584,5 @@ table td > .checkbox input[type="checkbox"] { } .tax-rule-lines .tax-rule-line { border-bottom: 1px solid $input-border; + padding-bottom: 5px; } diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index 2a22411ff9..198fc2e4d0 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -200,6 +200,40 @@ def test_reverse_charge_note(env): assert inv.foreign_currency_rate_date == date.today() +@pytest.mark.django_db +def test_custom_tax_note(env): + event, order = env + + tr = event.tax_rules.first() + tr.eu_reverse_charge = True + tr.home_country = Country('DE') + tr.custom_rules = json.dumps([ + { + 'country': 'PL', + 'address_type': '', + 'action': 'vat', + 'rate': '20', + 'invoice_text': { + 'de': 'Polnische Steuer anwendbar', + 'en': 'Polish tax applies' + } + } + ]) + tr.save() + + event.settings.set('invoice_language', 'en') + InvoiceAddress.objects.create(company='Acme Company', street='221B Baker Street', zipcode='12345', city='Warsaw', + country=Country('PL'), vat_id='PL123456780', vat_id_validated=True, order=order, + is_business=True) + + ocm = OrderChangeManager(order, None) + ocm.recalculate_taxes() + ocm.commit() + + inv = generate_invoice(order) + assert "Polish tax applies" in inv.additional_text + + @pytest.mark.django_db def test_reverse_charge_foreign_currency_data_too_old(env): event, order = env