Ask and validate VAT IDs for Switzerland (#2259)

Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Raphael Michel
2021-10-26 11:20:45 +02:00
committed by GitHub
parent 03a7a3303c
commit 5ea8a8ef82
9 changed files with 193 additions and 67 deletions

View File

@@ -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 = '<br/>'.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 = '<br/>'.join([
str(_('Optional, but it might be required for you to claim tax benefits on your invoice '
'depending on your and the sellers 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

View File

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

View File

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

View File

@@ -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 <https://pretix.eu/about/en/license>.
#
# 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
# <https://www.gnu.org/licenses/>.
#
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}')

View File

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

View File

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

View File

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

View File

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

View File

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