From 5ea8a8ef821b6f959f229d9f5af6aac20b669096 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 26 Oct 2021 11:20:45 +0200 Subject: [PATCH] Ask and validate VAT IDs for Switzerland (#2259) Co-authored-by: Richard Schreiber --- src/pretix/base/forms/questions.py | 59 ++++---- src/pretix/base/models/orders.py | 3 +- src/pretix/base/models/tax.py | 15 +- src/pretix/base/services/tax.py | 130 ++++++++++++++++++ src/pretix/base/settings.py | 12 +- src/pretix/control/views/orders.py | 32 ++--- src/pretix/static/pretixcontrol/js/ui/main.js | 4 +- src/pretix/static/pretixpresale/js/ui/main.js | 4 +- src/setup.py | 1 + 9 files changed, 193 insertions(+), 67 deletions(-) create mode 100644 src/pretix/base/services/tax.py diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index 2bed431160..1744641b76 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -37,13 +37,10 @@ import json import logging from decimal import Decimal from io import BytesIO -from urllib.error import HTTPError import dateutil.parser import pycountry import pytz -import vat_moss.errors -import vat_moss.id from babel import Locale from django import forms from django.conf import settings @@ -76,8 +73,9 @@ from pretix.base.i18n import ( get_babel_locale, get_language_without_region, language, ) from pretix.base.models import InvoiceAddress, Question, QuestionOption -from pretix.base.models.tax import ( - EU_COUNTRIES, cc_to_vat_prefix, is_eu_country, +from pretix.base.models.tax import VAT_ID_COUNTRIES, ask_for_vat_id +from pretix.base.services.tax import ( + VATIDFinalError, VATIDTemporaryError, validate_vat_id, ) from pretix.base.settings import ( COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SALUTATIONS, @@ -902,7 +900,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): 'data-display-dependency': '#id_is_business_1', 'autocomplete': 'organization', }), - 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-in-eu': ','.join(EU_COUNTRIES)}), + 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1', 'data-countries-with-vat-id': ','.join(VAT_ID_COUNTRIES)}), 'internal_reference': forms.TextInput, } labels = { @@ -922,6 +920,18 @@ class BaseInvoiceAddressForm(forms.ModelForm): super().__init__(*args, **kwargs) if not event.settings.invoice_address_vatid: del self.fields['vat_id'] + elif self.validate_vat_id: + self.fields['vat_id'].help_text = '
'.join([ + str(_('Optional, but depending on the country you reside in we might need to charge you ' + 'additional taxes if you do not enter it.')), + str(_('If you are registered in Switzerland, you can enter your UID instead.')), + ]) + else: + self.fields['vat_id'].help_text = '
'.join([ + str(_('Optional, but it might be required for you to claim tax benefits on your invoice ' + 'depending on your and the seller’s country of residence.')), + str(_('If you are registered in Switzerland, you can enter your UID instead.')), + ]) self.fields['country'].choices = CachedCountries() @@ -953,7 +963,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): self.fields['state'].widget.is_required = True # Without JavaScript the VAT ID field is not hidden, so we empty the field if a country outside the EU is selected. - if cc and not is_eu_country(cc) and fprefix + 'vat_id' in self.data: + if cc and not ask_for_vat_id(cc) and fprefix + 'vat_id' in self.data: self.data = self.data.copy() del self.data[fprefix + 'vat_id'] @@ -1003,7 +1013,7 @@ class BaseInvoiceAddressForm(forms.ModelForm): if not data.get('is_business'): data['company'] = '' data['vat_id'] = '' - if data.get('is_business') and not is_eu_country(data.get('country')): + if data.get('is_business') and not ask_for_vat_id(data.get('country')): data['vat_id'] = '' if self.event.settings.invoice_address_required: if data.get('is_business') and not data.get('company'): @@ -1026,36 +1036,19 @@ class BaseInvoiceAddressForm(forms.ModelForm): # Do not save the country if it is the only field set -- we don't know the user even checked it! self.cleaned_data['country'] = '' - if data.get('vat_id') and is_eu_country(data.get('country')) and data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))): - raise ValidationError(_('Your VAT ID does not match the selected country.')) - if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: pass - elif self.validate_vat_id and data.get('is_business') and is_eu_country(data.get('country')) and data.get('vat_id'): + elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'): try: - result = vat_moss.id.validate(data.get('vat_id')) - if result: - country_code, normalized_id, company_name = result - self.instance.vat_id_validated = True - self.instance.vat_id = normalized_id - except (vat_moss.errors.InvalidError, ValueError): - raise ValidationError(_('This VAT ID is not valid. Please re-check your input.')) - except vat_moss.errors.WebServiceUnavailableError: - logger.exception('VAT ID checking failed for country {}'.format(data.get('country'))) + normalized_id = validate_vat_id(data.get('vat_id'), str(data.get('country'))) + self.instance.vat_id_validated = True + self.instance.vat_id = normalized_id + except VATIDFinalError as e: + raise ValidationError(e.message) + except VATIDTemporaryError as e: self.instance.vat_id_validated = False if self.request and self.vat_warning: - messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of ' - 'your country is currently not available. We will therefore ' - 'need to charge VAT on your invoice. You can get the tax amount ' - 'back via the VAT reimbursement process.')) - except (vat_moss.errors.WebServiceError, HTTPError): - logger.exception('VAT ID checking failed for country {}'.format(data.get('country'))) - self.instance.vat_id_validated = False - if self.request and self.vat_warning: - messages.warning(self.request, _('Your VAT ID could not be checked, as the VAT checking service of ' - 'your country returned an incorrect result. We will therefore ' - 'need to charge VAT on your invoice. Please contact support to ' - 'resolve this manually.')) + messages.warning(self.request, e.message) else: self.instance.vat_id_validated = False diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 599affa3da..9e9879df65 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2612,8 +2612,7 @@ class InvoiceAddress(models.Model): country = FastCountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'), countries=CachedCountries) state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True) - vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'), - help_text=_('Only for business customers within the EU.')) + vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID')) vat_id_validated = models.BooleanField(default=False) custom_field = models.CharField(max_length=255, null=True, blank=True) internal_reference = models.TextField( diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index 978ef9474d..bc5ef661a5 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -25,7 +25,6 @@ from decimal import Decimal 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 _, pgettext from i18nfield.fields import I18nCharField from i18nfield.strings import LazyI18nString @@ -93,7 +92,7 @@ TAXED_ZERO = TaxedPrice( EU_COUNTRIES = { 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', - 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'GB' + 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', } EU_CURRENCIES = { 'BG': 'BGN', @@ -106,17 +105,21 @@ EU_CURRENCIES = { 'RO': 'RON', 'SE': 'SEK' } +VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH'} def is_eu_country(cc): cc = str(cc) - if cc == 'GB': - return now().astimezone(get_current_timezone()).year <= 2020 - else: - return cc in EU_COUNTRIES + return cc in EU_COUNTRIES + + +def ask_for_vat_id(cc): + cc = str(cc) + return cc in VAT_ID_COUNTRIES def cc_to_vat_prefix(country_code): + country_code = str(country_code) if country_code == 'GR': return 'EL' return country_code diff --git a/src/pretix/base/services/tax.py b/src/pretix/base/services/tax.py new file mode 100644 index 0000000000..b34f48efb5 --- /dev/null +++ b/src/pretix/base/services/tax.py @@ -0,0 +1,130 @@ +# +# This file is part of pretix (Community Edition). +# +# Copyright (C) 2014-2020 Raphael Michel and contributors +# Copyright (C) 2020-2021 rami.io GmbH and contributors +# +# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General +# Public License as published by the Free Software Foundation in version 3 of the License. +# +# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are +# applicable granting you additional permissions and placing additional restrictions on your usage of this software. +# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive +# this file, see . +# +# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied +# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along with this program. If not, see +# . +# +import logging +import re +from urllib.error import HTTPError + +import vat_moss.errors +import vat_moss.id +from django.utils.translation import gettext_lazy as _ +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 + +logger = logging.getLogger(__name__) + + +class VATIDError(Exception): + def __init__(self, message): + self.message = message + + +class VATIDFinalError(VATIDError): + pass + + +class VATIDTemporaryError(VATIDError): + pass + + +def _validate_vat_id_EU(vat_id, country_code): + if vat_id[:2] != cc_to_vat_prefix(country_code): + raise VATIDFinalError(_('Your VAT ID does not match the selected country.')) + + try: + result = vat_moss.id.validate(vat_id) + if result: + country_code, normalized_id, company_name = result + return normalized_id + except (vat_moss.errors.InvalidError, ValueError): + raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.')) + except vat_moss.errors.WebServiceUnavailableError: + logger.exception('VAT ID checking failed for country {}'.format(country_code)) + raise VATIDTemporaryError(_( + 'Your VAT ID could not be checked, as the VAT checking service of ' + 'your country is currently not available. We will therefore ' + 'need to charge VAT on your invoice. You can get the tax amount ' + 'back via the VAT reimbursement process.' + )) + except (vat_moss.errors.WebServiceError, HTTPError): + logger.exception('VAT ID checking failed for country {}'.format(country_code)) + raise VATIDTemporaryError(_( + 'Your VAT ID could not be checked, as the VAT checking service of ' + 'your country returned an incorrect result. We will therefore ' + 'need to charge VAT on your invoice. Please contact support to ' + 'resolve this manually.' + )) + + +def _validate_vat_id_CH(vat_id, country_code): + if vat_id[:3] != 'CHE': + raise VATIDFinalError(_('Your VAT ID does not match the selected country.')) + + vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', '')) + try: + transport = Transport(cache=SqliteCache()) + client = Client( + 'https://www.uid-wse-a.admin.ch/V5.0/PublicServices.svc?wsdl', + transport=transport + ) + if not client.service.ValidateUID(uid=vat_id): + raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.')) + return vat_id + except Fault as e: + if e.message == 'Data_validation_failed': + raise VATIDFinalError(_('This VAT ID is not valid. Please re-check your input.')) + elif e.message == 'Request_limit_exceeded': + logger.exception('VAT ID checking failed for country {} due to request limit'.format(country_code)) + raise VATIDTemporaryError(_( + 'Your VAT ID could not be checked, as the VAT checking service of ' + 'your country returned an incorrect result. We will therefore ' + 'need to charge VAT on your invoice. Please contact support to ' + 'resolve this manually.' + )) + else: + logger.exception('VAT ID checking failed for country {}'.format(country_code)) + raise VATIDTemporaryError(_( + 'Your VAT ID could not be checked, as the VAT checking service of ' + 'your country returned an incorrect result. We will therefore ' + 'need to charge VAT on your invoice. Please contact support to ' + 'resolve this manually.' + )) + except: + logger.exception('VAT ID checking failed for country {}'.format(country_code)) + raise VATIDTemporaryError(_( + 'Your VAT ID could not be checked, as the VAT checking service of ' + 'your country is currently not available. We will therefore ' + 'need to charge VAT on your invoice. You can get the tax amount ' + 'back via the VAT reimbursement process.' + )) + + +def validate_vat_id(vat_id, country_code): + country_code = str(country_code) + if is_eu_country(country_code): + return _validate_vat_id_EU(vat_id, country_code) + elif country_code == 'CH': + return _validate_vat_id_CH(vat_id, country_code) + + raise VATIDTemporaryError(f'VAT ID should not be entered for country {country_code}') diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index b0abbef8bb..f5937b2edf 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -48,10 +48,12 @@ from django.core.validators import ( MaxValueValidator, MinValueValidator, RegexValidator, ) from django.db.models import Model +from django.utils.functional import lazy from django.utils.text import format_lazy from django.utils.translation import ( - gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy, + gettext, gettext_lazy as _, gettext_noop, pgettext, pgettext_lazy, ) +from django_countries.fields import Country from hierarkey.models import GlobalSettingsBase, Hierarkey from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from i18nfield.strings import LazyI18nString @@ -61,7 +63,7 @@ from pretix.api.serializers.fields import ( ListMultipleChoiceField, UploadedFileField, ) from pretix.api.serializers.i18n import I18nField -from pretix.base.models.tax import TaxRule +from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule from pretix.base.reldate import ( RelativeDateField, RelativeDateTimeField, RelativeDateWrapper, SerializerRelativeDateField, SerializerRelativeDateTimeField, @@ -370,7 +372,11 @@ DEFAULTS = { 'serializer_class': serializers.BooleanField, 'form_kwargs': dict( label=_("Ask for VAT ID"), - help_text=_("Does only work if an invoice address is asked for. VAT ID is not required."), + help_text=format_lazy( + _("Only works if an invoice address is asked for. VAT ID is never required and only requested from " + "business customers in the following countries: {countries}"), + countries=lazy(lambda *args: ', '.join(sorted(gettext(Country(cc).name) for cc in VAT_ID_COUNTRIES)), str)() + ), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_invoice_address_asked'}), ) }, diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 258d9232cd..68986d5b41 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -42,7 +42,6 @@ from datetime import datetime, time, timedelta from decimal import Decimal, DecimalException from urllib.parse import quote, urlencode -import vat_moss.id from django import forms from django.conf import settings from django.contrib import messages @@ -83,7 +82,7 @@ from pretix.base.models import ( from pretix.base.models.orders import ( CancellationRequest, OrderFee, OrderPayment, OrderPosition, OrderRefund, ) -from pretix.base.models.tax import cc_to_vat_prefix, is_eu_country +from pretix.base.models.tax import ask_for_vat_id from pretix.base.payment import PaymentException from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets @@ -103,6 +102,9 @@ from pretix.base.services.orders import ( notify_user_changed_order, reactivate_order, ) from pretix.base.services.stats import order_overview +from pretix.base.services.tax import ( + VATIDFinalError, VATIDTemporaryError, validate_vat_id, +) from pretix.base.services.tickets import generate from pretix.base.signals import ( order_modified, register_data_exporters, register_ticket_outputs, @@ -1330,26 +1332,18 @@ class OrderCheckVATID(OrderView): messages.error(self.request, _('No country specified.')) return redirect(self.get_order_url()) - if not is_eu_country(ia.country): - messages.error(self.request, _('VAT ID could not be checked since a non-EU country has been ' - 'specified.')) - return redirect(self.get_order_url()) - - if ia.vat_id[:2] != cc_to_vat_prefix(str(ia.country)): - messages.error(self.request, _('Your VAT ID does not match the selected country.')) + if not ask_for_vat_id(ia.country): + messages.error(self.request, _('VAT ID could not be checked since this country is not supported.')) return redirect(self.get_order_url()) try: - result = vat_moss.id.validate(ia.vat_id) - if result: - country_code, normalized_id, company_name = result - ia.vat_id_validated = True - ia.vat_id = normalized_id - ia.save() - except vat_moss.errors.InvalidError: - messages.error(self.request, _('This VAT ID is not valid.')) - except vat_moss.errors.WebServiceUnavailableError: - logger.exception('VAT ID checking failed for country {}'.format(ia.country)) + normalized_id = validate_vat_id(ia.vat_id, str(ia.country)) + ia.vat_id_validated = True + ia.vat_id = normalized_id + ia.save() + except VATIDFinalError as e: + messages.error(self.request, e.message) + except VATIDTemporaryError: messages.error(self.request, _('The VAT ID could not be checked, as the VAT checking service of ' 'the country is currently not available.')) else: diff --git a/src/pretix/static/pretixcontrol/js/ui/main.js b/src/pretix/static/pretixcontrol/js/ui/main.js index 4fd8613210..fed379cfd3 100644 --- a/src/pretix/static/pretixcontrol/js/ui/main.js +++ b/src/pretix/static/pretixcontrol/js/ui/main.js @@ -355,14 +355,14 @@ var form_handlers = function (el) { dependency.closest('.form-group').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update); }); - $("input[name$=vat_id][data-countries-in-eu]").each(function () { + $("input[name$=vat_id][data-countries-with-vat-id]").each(function () { var dependent = $(this), dependency_country = $(this).closest(".panel-body, form").find('select[name$=country]'), dependency_id_is_business_1 = $(this).closest(".panel-body, form").find('input[id$=id_is_business_1]'), update = function (ev) { if (dependency_id_is_business_1.length && !dependency_id_is_business_1.prop("checked")) { dependent.closest(".form-group").hide(); - } else if (dependent.attr('data-countries-in-eu').split(',').includes(dependency_country.val())) { + } else if (dependent.attr('data-countries-with-vat-id').split(',').includes(dependency_country.val())) { dependent.closest(".form-group").show(); } else { dependent.closest(".form-group").hide(); diff --git a/src/pretix/static/pretixpresale/js/ui/main.js b/src/pretix/static/pretixpresale/js/ui/main.js index 1d2d92567c..3c3c13f8e4 100644 --- a/src/pretix/static/pretixpresale/js/ui/main.js +++ b/src/pretix/static/pretixpresale/js/ui/main.js @@ -403,14 +403,14 @@ $(function () { dependency.closest('.form-group, form').find('input[name=' + dependency.attr("name") + ']').on("dp.change", update); }); - $("input[name$=vat_id][data-countries-in-eu]").each(function () { + $("input[name$=vat_id][data-countries-with-vat-id]").each(function () { var dependent = $(this), dependency_country = $(this).closest(".panel-body, form").find('select[name$=country]'), dependency_id_is_business_1 = $(this).closest(".panel-body, form").find('input[id$=id_is_business_1]'), update = function (ev) { if (dependency_id_is_business_1.length && !dependency_id_is_business_1.prop("checked")) { dependent.closest(".form-group").hide(); - } else if (dependent.attr('data-countries-in-eu').split(',').includes(dependency_country.val())) { + } else if (dependent.attr('data-countries-with-vat-id').split(',').includes(dependency_country.val())) { dependent.closest(".form-group").show(); } else { dependent.closest(".form-group").hide(); diff --git a/src/setup.py b/src/setup.py index e26dfe9e71..6ab9001870 100644 --- a/src/setup.py +++ b/src/setup.py @@ -228,6 +228,7 @@ setup( 'vat_moss_forked==2020.3.20.0.11.0', 'vobject==0.9.*', 'webauthn==0.4.*', + 'zeep==4.1.*' ], extras_require={ 'dev': [