diff --git a/pyproject.toml b/pyproject.toml index 4d89864e54..cde1411327 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -99,7 +99,6 @@ dependencies = [ "tlds>=2020041600", "tqdm==4.*", "ua-parser==1.0.*", - "vat_moss_forked==2020.3.20.0.11.0", "vobject==0.9.*", "webauthn==2.7.*", "zeep==4.3.*" diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 1f1c40cf28..325e24c7ce 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -795,6 +795,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_address_asked', 'invoice_address_required', 'invoice_address_vatid', + 'invoice_address_vatid_required_countries', 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', @@ -943,6 +944,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'invoice_address_asked', 'invoice_address_required', 'invoice_address_vatid', + 'invoice_address_vatid_required_countries', 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index bb56ceb9c3..7d810e3e84 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -83,7 +83,7 @@ from pretix.base.invoicing.transmission import ( from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption from pretix.base.models.tax import ask_for_vat_id from pretix.base.services.tax import ( - VATIDFinalError, VATIDTemporaryError, validate_vat_id, + VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id, ) from pretix.base.settings import ( COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, @@ -1165,13 +1165,11 @@ class BaseInvoiceAddressForm(forms.ModelForm): 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.')), ]) transmission_type_choices = [ @@ -1358,13 +1356,24 @@ class BaseInvoiceAddressForm(forms.ModelForm): "transmission method.")} ) + vat_id_applicable = ( + 'vat_id' in self.fields and + data.get('is_business') and + ask_for_vat_id(data.get('country')) + ) + vat_id_required = vat_id_applicable and str(data.get('country')) in self.event.settings.invoice_address_vatid_required_countries + if vat_id_required and not data.get('vat_id'): + raise ValidationError({ + "vat_id": _("This field is required.") + }) + 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 ask_for_vat_id(data.get('country')) and data.get('vat_id'): + pass # Skip re-validation if it is validated + elif self.validate_vat_id and vat_id_applicable: try: 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 + self.instance.vat_id = data['vat_id'] = normalized_id except VATIDFinalError as e: if self.all_optional: self.instance.vat_id_validated = False @@ -1372,6 +1381,9 @@ class BaseInvoiceAddressForm(forms.ModelForm): else: raise ValidationError({"vat_id": e.message}) except VATIDTemporaryError as e: + # We couldn't check it online, but we can still normalize it + normalized_id = normalize_vat_id(data.get('vat_id'), str(data.get('country'))) + self.instance.vat_id = data['vat_id'] = normalized_id self.instance.vat_id_validated = False if self.request and self.vat_warning: messages.warning(self.request, e.message) diff --git a/src/pretix/base/invoicing/pdf.py b/src/pretix/base/invoicing/pdf.py index a97d713744..af9d3f0fd5 100644 --- a/src/pretix/base/invoicing/pdf.py +++ b/src/pretix/base/invoicing/pdf.py @@ -32,7 +32,6 @@ from itertools import groupby from typing import Tuple import bleach -import vat_moss.exchange_rates from bidi import get_display from django.contrib.staticfiles import finders from django.db.models import Sum @@ -1059,7 +1058,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer): def fmt(val): try: - return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display) + return money_filter(val, self.invoice.foreign_currency_display) except ValueError: return localize(val) + ' ' + self.invoice.foreign_currency_display diff --git a/src/pretix/base/services/tax.py b/src/pretix/base/services/tax.py index 781374c568..2a05d36ecc 100644 --- a/src/pretix/base/services/tax.py +++ b/src/pretix/base/services/tax.py @@ -27,7 +27,6 @@ from decimal import Decimal from xml.etree import ElementTree import requests -import vat_moss.id from django.conf import settings from django.utils.translation import gettext_lazy as _ from zeep import Client, Transport @@ -42,14 +41,142 @@ logger = logging.getLogger(__name__) error_messages = { 'unavailable': _( '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.' + 'your country is currently not available. We will therefore need to ' + 'charge you the same tax rate as if you did not enter a VAT ID.' ), 'invalid': _('This VAT ID is not valid. Please re-check your input.'), 'country_mismatch': _('Your VAT ID does not match the selected country.'), } +VAT_ID_PATTERNS = { + # Patterns generated by consulting the following URLs: + # + # - http://en.wikipedia.org/wiki/VAT_identification_number + # - http://ec.europa.eu/taxation_customs/vies/faq.html + # - https://euipo.europa.eu/tunnel-web/secure/webdav/guest/document_library/Documents/COSME/VAT%20numbers%20EU.pdf + # - http://www.skatteetaten.no/en/International-pages/Felles-innhold-benyttes-i-flere-malgrupper/Brochure/Guide-to-value-added-tax-in-Norway/?chapter=7159 + 'AT': { # Austria + 'regex': '^U\\d{8}$', + 'country_code': 'AT' + }, + 'BE': { # Belgium + 'regex': '^(1|0?)\\d{9}$', + 'country_code': 'BE' + }, + 'BG': { # Bulgaria + 'regex': '^\\d{9,10}$', + 'country_code': 'BG' + }, + 'CH': { # Switzerland + 'regex': '^\\dE{9}$', + 'country_code': 'CH' + }, + 'CY': { # Cyprus + 'regex': '^\\d{8}[A-Z]$', + 'country_code': 'CY' + }, + 'CZ': { # Czech Republic + 'regex': '^\\d{8,10}$', + 'country_code': 'CZ' + }, + 'DE': { # Germany + 'regex': '^\\d{9}$', + 'country_code': 'DE' + }, + 'DK': { # Denmark + 'regex': '^\\d{8}$', + 'country_code': 'DK' + }, + 'EE': { # Estonia + 'regex': '^\\d{9}$', + 'country_code': 'EE' + }, + 'EL': { # Greece + 'regex': '^\\d{9}$', + 'country_code': 'GR' + }, + 'ES': { # Spain + 'regex': '^[A-Z0-9]\\d{7}[A-Z0-9]$', + 'country_code': 'ES' + }, + 'FI': { # Finland + 'regex': '^\\d{8}$', + 'country_code': 'FI' + }, + 'FR': { # France + 'regex': '^[A-Z0-9]{2}\\d{9}$', + 'country_code': 'FR' + }, + 'GB': { # United Kingdom + 'regex': '^(GD\\d{3}|HA\\d{3}|\\d{9}|\\d{12})$', + 'country_code': 'GB' + }, + 'HR': { # Croatia + 'regex': '^\\d{11}$', + 'country_code': 'HR' + }, + 'HU': { # Hungary + 'regex': '^\\d{8}$', + 'country_code': 'HU' + }, + 'IE': { # Ireland + 'regex': '^(\\d{7}[A-Z]{1,2}|\\d[A-Z+*]\\d{5}[A-Z])$', + 'country_code': 'IE' + }, + 'IT': { # Italy + 'regex': '^\\d{11}$', + 'country_code': 'IT' + }, + 'LT': { # Lithuania + 'regex': '^(\\d{9}|\\d{12})$', + 'country_code': 'LT' + }, + 'LU': { # Luxembourg + 'regex': '^\\d{8}$', + 'country_code': 'LU' + }, + 'LV': { # Latvia + 'regex': '^\\d{11}$', + 'country_code': 'LV' + }, + 'MT': { # Malta + 'regex': '^\\d{8}$', + 'country_code': 'MT' + }, + 'NL': { # Netherlands + 'regex': '^\\d{9}B\\d{2}$', + 'country_code': 'NL' + }, + 'NO': { # Norway + 'regex': '^\\d{9}MVA$', + 'country_code': 'NO' + }, + 'PL': { # Poland + 'regex': '^\\d{10}$', + 'country_code': 'PL' + }, + 'PT': { # Portugal + 'regex': '^\\d{9}$', + 'country_code': 'PT' + }, + 'RO': { # Romania + 'regex': '^\\d{2,10}$', + 'country_code': 'RO' + }, + 'SE': { # Sweden + 'regex': '^\\d{12}$', + 'country_code': 'SE' + }, + 'SI': { # Slovenia + 'regex': '^\\d{8}$', + 'country_code': 'SI' + }, + 'SK': { # Slovakia + 'regex': '^\\d{10}$', + 'country_code': 'SK' + }, +} + class VATIDError(Exception): def __init__(self, message): @@ -64,13 +191,57 @@ class VATIDTemporaryError(VATIDError): pass +def normalize_vat_id(vat_id, country_code): + """ + Accepts a VAT ID and normaizes it, getting rid of spaces, periods, dashes + etc and converting it to upper case. + + Original function from https://github.com/wbond/vat_moss-python + Copyright (c) 2015 Will Bond + MIT License + """ + if not vat_id: + return None + + if not isinstance(vat_id, str): + raise TypeError('VAT ID is not a string') + + if len(vat_id) < 3: + raise ValueError('VAT ID must be at least three character long') + + # Normalize the ID for simpler regexes + vat_id = re.sub('\\s+', '', vat_id) + vat_id = vat_id.replace('-', '') + vat_id = vat_id.replace('.', '') + vat_id = vat_id.upper() + + # Clean the different shapes a number can take in Switzerland depending on purpse + if country_code == "CH": + vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', '')) + + # Fix people using GR prefix for Greece + if vat_id[0:2] == "GR" and country_code == "GR": + vat_id = "EL" + vat_id[2:] + + # Check if we already have a valid country prefix. If not, we try to figure out if we can + # add one, since in some countries (e.g. Italy) it's very custom to enter it without the prefix + if vat_id[:2] in VAT_ID_PATTERNS and re.match(VAT_ID_PATTERNS[vat_id[0:2]]['regex'], vat_id[2:]): + # Prefix set and prefix matches pattern, nothing to do + pass + elif re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], vat_id): + # Prefix not set but adding it fixes pattern + vat_id = cc_to_vat_prefix(country_code) + vat_id + else: + # We have no idea what this is + pass + + return vat_id + + def _validate_vat_id_NO(vat_id, country_code): # Inspired by vat_moss library - if not vat_id.startswith("NO"): - # prefix is not usually used in Norway, but expected by vat_moss library - vat_id = "NO" + vat_id try: - vat_id = vat_moss.id.normalize(vat_id) + vat_id = normalize_vat_id(vat_id, country_code) except ValueError: raise VATIDFinalError(error_messages['invalid']) @@ -104,7 +275,7 @@ def _validate_vat_id_NO(vat_id, country_code): def _validate_vat_id_EU(vat_id, country_code): # Inspired by vat_moss library try: - vat_id = vat_moss.id.normalize(vat_id) + vat_id = normalize_vat_id(vat_id, country_code) except ValueError: raise VATIDFinalError(error_messages['invalid']) @@ -112,11 +283,10 @@ def _validate_vat_id_EU(vat_id, country_code): raise VATIDFinalError(error_messages['invalid']) number = vat_id[2:] - if vat_id[:2] != cc_to_vat_prefix(country_code): raise VATIDFinalError(error_messages['country_mismatch']) - if not re.match(vat_moss.id.ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number): + if not re.match(VAT_ID_PATTERNS[cc_to_vat_prefix(country_code)]['regex'], number): raise VATIDFinalError(error_messages['invalid']) # We are relying on the country code of the normalized VAT-ID and not the user/InvoiceAddress-provided @@ -175,9 +345,12 @@ def _validate_vat_id_EU(vat_id, country_code): 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.')) + raise VATIDFinalError(error_messages['country_mismatch']) - vat_id = re.sub('[^A-Z0-9]', '', vat_id.replace('HR', '').replace('MWST', '')) + try: + vat_id = normalize_vat_id(vat_id, country_code) + except ValueError: + raise VATIDFinalError(error_messages['invalid']) try: transport = Transport( cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")), diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index c2422fe692..e6d123d060 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -629,13 +629,40 @@ DEFAULTS = { 'form_kwargs': dict( label=_("Ask for VAT ID"), 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}"), + _("Only works if an invoice address is asked for. VAT ID is 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'}), ) }, + 'invoice_address_vatid_required_countries': { + 'default': ['IT', 'GR'], + 'type': list, + 'form_class': forms.MultipleChoiceField, + 'serializer_class': serializers.MultipleChoiceField, + 'serializer_kwargs': dict( + choices=lazy( + lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]), + list + )(), + ), + 'form_kwargs': dict( + label=_("Require VAT ID in"), + choices=lazy( + lambda *args: sorted([(cc, gettext(Country(cc).name)) for cc in VAT_ID_COUNTRIES], key=lambda c: c[1]), + list + )(), + help_text=format_lazy( + _("VAT ID is optional by default, because not all businesses are assigned a VAT ID in all countries. " + "VAT ID will be required for all business addresses in the selected countries."), + ), + widget=forms.CheckboxSelectMultiple(attrs={ + "class": "scrolling-multiple-choice", + 'data-display-dependency': '#id_invoice_address_vatid' + }), + ) + }, 'invoice_address_explanation_text': { 'default': '', 'type': LazyI18nString, diff --git a/src/pretix/base/views/js_helpers.py b/src/pretix/base/views/js_helpers.py index 7da2c83952..e1aee64a51 100644 --- a/src/pretix/base/views/js_helpers.py +++ b/src/pretix/base/views/js_helpers.py @@ -22,7 +22,7 @@ import pycountry from django.http import JsonResponse from django.shortcuts import get_object_or_404 -from django.utils.translation import pgettext +from django.utils.translation import gettext, pgettext, pgettext_lazy from django_countries.fields import Country from django_scopes import scope @@ -36,6 +36,22 @@ from pretix.base.settings import ( COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, ) +VAT_ID_LABELS = { + # VAT ID is a EU concept and Switzerland has a distinct, but differently-named concept + "CH": pgettext_lazy("tax_id_swiss", "UID"), # Translators: Only translate to French (IDE) and Italien (IDI), otherwise keep the same + + # Awareness around VAT IDs differes by EU country. For example, in Germany the VAT ID is assigned + # separately to each company and only used in cross-country transactions. Therefore, it makes sense + # to call it just "VAT ID" on the form, and people will either know their VAT ID or they don't. + # In contrast, in Italy the EU-compatible VAT ID is not separately assigned, but is just "IT" + the national tax + # number (Partita IVA) and also used on domestic transactions. So someone who never purchased something international + # for their company, might still know the value, if we call it the right way and not just "VAT ID". + "IT": pgettext_lazy("tax_id_italy", "VAT ID / P.IVA"), # Translators: Translate to only "P.IVA" in Italian, keep second part as-is in other languages + "GR": pgettext_lazy("tax_id_greece", "VAT ID / TIN"), # Translators: Translate to only "ΑΦΜ" in Greek + "ES": pgettext_lazy("tax_id_spain", "VAT ID / NIF"), # Translators: Translate to only "NIF" in Spanish + "PT": pgettext_lazy("tax_id_portugal", "VAT ID / NIF"), # Translators: Translate to only "NIF" in Portuguese +} + def _info(cc): info = { @@ -47,7 +63,12 @@ def _info(cc): 'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False, 'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')), }, - 'vat_id': {'visible': cc in VAT_ID_COUNTRIES, 'required': False}, + 'vat_id': { + 'visible': cc in VAT_ID_COUNTRIES, + 'required': False, + 'label': VAT_ID_LABELS.get(cc, gettext("VAT ID")), + 'helptext_visible': True, + }, } if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS: return {'data': [], **info} @@ -124,4 +145,10 @@ def address_form(request): "required": transmission_type.identifier == selected_transmission_type and k in required } + if is_business and country in event.settings.invoice_address_vatid_required_countries and info["vat_id"]["visible"]: + info["vat_id"]["required"] = True + if info["vat_id"]["required"]: + # The help text explains that it is optional, so we want to hide that if it is required + info["vat_id"]["helptext_visible"] = False + return JsonResponse(info) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 8402d35e21..2be60e9b6d 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -927,6 +927,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): 'invoice_address_asked', 'invoice_address_required', 'invoice_address_vatid', + 'invoice_address_vatid_required_countries', 'invoice_address_company_required', 'invoice_address_beneficiary', 'invoice_address_custom_field', diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index 0b9751dfd0..6f42727bc4 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -43,6 +43,7 @@ {% bootstrap_field form.invoice_name_required layout="control" %} {% bootstrap_field form.invoice_address_company_required layout="control" %} {% bootstrap_field form.invoice_address_vatid layout="control" %} + {% bootstrap_field form.invoice_address_vatid_required_countries layout="control" %} {% bootstrap_field form.invoice_address_beneficiary layout="control" %} {% bootstrap_field form.invoice_address_not_asked_free layout="control" %} {% bootstrap_field form.invoice_address_custom_field layout="control" %} diff --git a/src/pretix/static/pretixbase/js/addressform.js b/src/pretix/static/pretixbase/js/addressform.js index 1dee56fe2b..5933fb98c6 100644 --- a/src/pretix/static/pretixbase/js/addressform.js +++ b/src/pretix/static/pretixbase/js/addressform.js @@ -82,6 +82,9 @@ $(function () { if ('label' in options) { dependent.closest(".form-group").find(".control-label").text(options.label); } + if ('helptext_visible' in options) { + dependent.closest(".form-group").find(".help-block").toggle(options.helptext_visible); + } const required = 'required' in options && visible && ( (options.required === 'if_any' && isAnyRequired) || diff --git a/src/tests/base/test_js_helpers.py b/src/tests/base/test_js_helpers.py index 654abeb868..05ae933441 100644 --- a/src/tests/base/test_js_helpers.py +++ b/src/tests/base/test_js_helpers.py @@ -34,7 +34,7 @@ def test_no_invoice_address(client): 'data': [], 'state': {'label': 'State', 'required': False, 'visible': False}, 'street': {'required': 'if_any'}, - 'vat_id': {'required': False, 'visible': True}, + 'vat_id': {'helptext_visible': True, 'label': 'VAT ID', 'required': False, 'visible': True}, 'zipcode': {'required': 'if_any'} } @@ -44,7 +44,7 @@ def test_no_invoice_address(client): 'data': [], 'state': {'label': 'State', 'required': False, 'visible': False}, 'street': {'required': 'if_any'}, - 'vat_id': {'required': False, 'visible': False}, + 'vat_id': {'helptext_visible': True, 'label': 'VAT ID', 'required': False, 'visible': False}, 'zipcode': {'required': False} } @@ -98,7 +98,7 @@ def test_provider_only_email_available(client, event): 'transmission_peppol_participant_id': {'required': False, 'visible': False}, 'transmission_type': {'visible': False}, 'transmission_types': [{'code': 'email', 'name': 'Email'}], - 'vat_id': {'required': False, 'visible': True}, + 'vat_id': {'helptext_visible': True, 'label': 'VAT ID', 'required': False, 'visible': True}, 'zipcode': {'required': 'if_any'} } @@ -123,7 +123,7 @@ def test_provider_italy_sdi_not_enforced_when_optional(client, event): 'transmission_peppol_participant_id': {'required': False, 'visible': False}, 'transmission_type': {'visible': True}, 'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}], - 'vat_id': {'required': False, 'visible': True}, + 'vat_id': {'helptext_visible': True, 'label': 'VAT ID / P.IVA', 'required': False, 'visible': True}, 'zipcode': {'required': 'if_any'} } @@ -148,7 +148,7 @@ def test_provider_italy_sdi_enforced_individual(client, event): 'transmission_peppol_participant_id': {'required': False, 'visible': False}, 'transmission_type': {'visible': True}, 'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}], - 'vat_id': {'required': False, 'visible': True}, + 'vat_id': {'helptext_visible': True, 'label': 'VAT ID / P.IVA', 'required': False, 'visible': True}, 'zipcode': {'required': True} } @@ -174,11 +174,37 @@ def test_provider_italy_sdi_enforced_business(client, event): 'transmission_peppol_participant_id': {'required': False, 'visible': False}, 'transmission_type': {'visible': True}, 'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}], - 'vat_id': {'required': True, 'visible': True}, + 'vat_id': {'helptext_visible': False, 'label': 'VAT ID / P.IVA', 'required': True, 'visible': True}, 'zipcode': {'required': True} } +@pytest.mark.django_db +def test_vat_id_enforced(client, event): + response = client.get( + '/js_helpers/address_form/?country=GR&invoice=true&organizer=org&event=ev' + '&is_business=business' + ) + assert response.status_code == 200 + d = response.json() + del d['data'] + assert d == { + 'city': {'required': 'if_any'}, + 'state': {'label': 'State', 'required': False, 'visible': False}, + 'street': {'required': 'if_any'}, + 'transmission_email_address': {'required': False, 'visible': False}, + 'transmission_email_other': {'required': False, 'visible': False}, + 'transmission_it_sdi_codice_fiscale': {'required': False, 'visible': False}, + 'transmission_it_sdi_pec': {'required': False, 'visible': False}, + 'transmission_it_sdi_recipient_code': {'required': False, 'visible': False}, + 'transmission_peppol_participant_id': {'required': False, 'visible': False}, + 'transmission_type': {'visible': True}, + 'transmission_types': [{'code': 'email', 'name': 'Email'}, {'code': 'peppol', 'name': 'Peppol'}], + 'vat_id': {'helptext_visible': False, 'label': 'VAT ID / TIN', 'required': True, 'visible': True}, + 'zipcode': {'required': 'if_any'} + } + + @pytest.mark.django_db def test_email_peppol_choice(client, event): response = client.get( @@ -203,7 +229,7 @@ def test_email_peppol_choice(client, event): {'code': 'email', 'name': 'Email'}, {'code': 'peppol', 'name': 'Peppol'}, ], - 'vat_id': {'required': False, 'visible': True}, + 'vat_id': {'helptext_visible': True, 'label': 'VAT ID', 'required': False, 'visible': True}, 'zipcode': {'required': 'if_any'} } @@ -229,6 +255,6 @@ def test_email_peppol_choice(client, event): {'code': 'email', 'name': 'Email'}, {'code': 'peppol', 'name': 'Peppol'}, ], - 'vat_id': {'required': False, 'visible': True}, + 'vat_id': {'helptext_visible': True, 'label': 'VAT ID', 'required': False, 'visible': True}, 'zipcode': {'required': True} } diff --git a/src/tests/base/test_vat_id_validation.py b/src/tests/base/test_vat_id_validation.py index 1963cd1302..be9c81018f 100644 --- a/src/tests/base/test_vat_id_validation.py +++ b/src/tests/base/test_vat_id_validation.py @@ -24,7 +24,7 @@ import responses from requests import Timeout from pretix.base.services.tax import ( - VATIDFinalError, VATIDTemporaryError, validate_vat_id, + VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id, ) @@ -51,6 +51,18 @@ def test_eu_country_mismatch(): validate_vat_id('AT12345', 'DE') +@responses.activate +def test_normalize(): + assert normalize_vat_id('AT U 12345678', 'AT') == 'ATU12345678' + assert normalize_vat_id('U12345678', 'AT') == 'ATU12345678' + assert normalize_vat_id('IT.123.456.789.00', 'IT') == 'IT12345678900' + assert normalize_vat_id('12345678900', 'IT') == 'IT12345678900' + assert normalize_vat_id('123456789MVA', 'NO') == "NO123456789MVA" + assert normalize_vat_id('CHE 123456789 MWST', 'CH') == "CHE123456789" + # Bad combination is left for validation + assert normalize_vat_id('ATU12345678', 'IT') == 'ATU12345678' + + @responses.activate def test_eu_server_down(): def _callback(request): diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 17d43e9d91..22d7b4c6d8 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -411,6 +411,69 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): with scopes_disabled(): ia = InvoiceAddress.objects.get(pk=self.client.session['carts'][self.session_key].get('invoice_address')) + assert ia.vat_id == "AT123456" + assert not ia.vat_id_validated + + def test_reverse_charge_vatid_required(self): + self.event.settings.invoice_address_vatid = True + self.event.settings.invoice_address_vatid_required_countries = ["AT"] + + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + resp = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '1234', + 'city': 'Here', + 'country': 'AT', + 'email': 'admin@localhost', + 'transmission_type': 'email', + }, follow=True) + assert 'has-error' in resp.content.decode() + + def test_reverse_charge_vatid_check_unavailable_but_required(self): + self.tr19.eu_reverse_charge = True + self.tr19.home_country = Country('DE') + self.tr19.save() + self.event.settings.invoice_address_vatid = True + self.event.settings.invoice_address_vatid_required_countries = ["AT"] + + with scopes_disabled(): + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + with mock.patch('pretix.base.services.tax._validate_vat_id_EU') as mock_validate: + def raiser(*args, **kwargs): + raise VATIDTemporaryError('temp') + + mock_validate.side_effect = raiser + self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { + 'is_business': 'business', + 'company': 'Foo', + 'name': 'Bar', + 'street': 'Baz', + 'zipcode': '1234', + 'city': 'Here', + 'country': 'AT', + 'vat_id': 'AT123456', + 'email': 'admin@localhost', + 'transmission_type': 'email', + }, follow=True) + + cr1.refresh_from_db() + assert cr1.price == Decimal('23.00') + + with scopes_disabled(): + ia = InvoiceAddress.objects.get(pk=self.client.session['carts'][self.session_key].get('invoice_address')) + assert ia.vat_id == "AT123456" assert not ia.vat_id_validated def test_reverse_charge_keep_gross(self): @@ -448,6 +511,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): with scopes_disabled(): ia = InvoiceAddress.objects.get(pk=self.client.session['carts'][self.session_key].get('invoice_address')) + assert ia.vat_id == "AT123456" assert ia.vat_id_validated def test_custom_tax_rules(self): @@ -1452,7 +1516,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): 'transmission_type': 'it_sdi', 'vat_id': '', }, follow=True) - assert "This field is required for the selected type" in response.content.decode() + assert "This field is required" in response.content.decode() response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), { 'is_business': 'business', @@ -1468,6 +1532,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): 'state': 'MI', 'email': 'admin@localhost', 'transmission_type': 'email', + 'vat_id': 'IT01234567890', }, follow=True) assert "must be used for this country" in response.content.decode()