Invoice address: Improve VAT ID input (#5647)

* Remove unmaintained depdendency vat_moss

* VAT ID normalization: Auto-add country codes

* VAT ID: County-specific labels

* Invoice address: Allow to set VAT ID as required per country

* Fix failing tests

* Update src/pretix/base/settings.py

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

* Review fixes

---------

Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
Raphael Michel
2025-12-03 16:48:19 +01:00
committed by GitHub
parent 051eb78312
commit 5a1bcae085
13 changed files with 383 additions and 36 deletions

View File

@@ -99,7 +99,6 @@ dependencies = [
"tlds>=2020041600", "tlds>=2020041600",
"tqdm==4.*", "tqdm==4.*",
"ua-parser==1.0.*", "ua-parser==1.0.*",
"vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==2.7.*", "webauthn==2.7.*",
"zeep==4.3.*" "zeep==4.3.*"

View File

@@ -795,6 +795,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_address_asked', 'invoice_address_asked',
'invoice_address_required', 'invoice_address_required',
'invoice_address_vatid', 'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required', 'invoice_address_company_required',
'invoice_address_beneficiary', 'invoice_address_beneficiary',
'invoice_address_custom_field', 'invoice_address_custom_field',
@@ -943,6 +944,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'invoice_address_asked', 'invoice_address_asked',
'invoice_address_required', 'invoice_address_required',
'invoice_address_vatid', 'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required', 'invoice_address_company_required',
'invoice_address_beneficiary', 'invoice_address_beneficiary',
'invoice_address_custom_field', 'invoice_address_custom_field',

View File

@@ -83,7 +83,7 @@ from pretix.base.invoicing.transmission import (
from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption from pretix.base.models import InvoiceAddress, Item, Question, QuestionOption
from pretix.base.models.tax import ask_for_vat_id from pretix.base.models.tax import ask_for_vat_id
from pretix.base.services.tax import ( from pretix.base.services.tax import (
VATIDFinalError, VATIDTemporaryError, validate_vat_id, VATIDFinalError, VATIDTemporaryError, normalize_vat_id, validate_vat_id,
) )
from pretix.base.settings import ( from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL,
@@ -1165,13 +1165,11 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.fields['vat_id'].help_text = '<br/>'.join([ self.fields['vat_id'].help_text = '<br/>'.join([
str(_('Optional, but depending on the country you reside in we might need to charge you ' str(_('Optional, but depending on the country you reside in we might need to charge you '
'additional taxes if you do not enter it.')), 'additional taxes if you do not enter it.')),
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
]) ])
else: else:
self.fields['vat_id'].help_text = '<br/>'.join([ self.fields['vat_id'].help_text = '<br/>'.join([
str(_('Optional, but it might be required for you to claim tax benefits on your invoice ' 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.')), 'depending on your and the sellers country of residence.')),
str(_('If you are registered in Switzerland, you can enter your UID instead.')),
]) ])
transmission_type_choices = [ transmission_type_choices = [
@@ -1358,13 +1356,24 @@ class BaseInvoiceAddressForm(forms.ModelForm):
"transmission method.")} "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: if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass pass # Skip re-validation if it is validated
elif self.validate_vat_id and data.get('is_business') and ask_for_vat_id(data.get('country')) and data.get('vat_id'): elif self.validate_vat_id and vat_id_applicable:
try: try:
normalized_id = validate_vat_id(data.get('vat_id'), str(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_validated = True
self.instance.vat_id = normalized_id self.instance.vat_id = data['vat_id'] = normalized_id
except VATIDFinalError as e: except VATIDFinalError as e:
if self.all_optional: if self.all_optional:
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
@@ -1372,6 +1381,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
else: else:
raise ValidationError({"vat_id": e.message}) raise ValidationError({"vat_id": e.message})
except VATIDTemporaryError as e: 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 self.instance.vat_id_validated = False
if self.request and self.vat_warning: if self.request and self.vat_warning:
messages.warning(self.request, e.message) messages.warning(self.request, e.message)

View File

@@ -32,7 +32,6 @@ from itertools import groupby
from typing import Tuple from typing import Tuple
import bleach import bleach
import vat_moss.exchange_rates
from bidi import get_display from bidi import get_display
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.db.models import Sum from django.db.models import Sum
@@ -1059,7 +1058,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def fmt(val): def fmt(val):
try: try:
return vat_moss.exchange_rates.format(val, self.invoice.foreign_currency_display) return money_filter(val, self.invoice.foreign_currency_display)
except ValueError: except ValueError:
return localize(val) + ' ' + self.invoice.foreign_currency_display return localize(val) + ' ' + self.invoice.foreign_currency_display

View File

@@ -27,7 +27,6 @@ from decimal import Decimal
from xml.etree import ElementTree from xml.etree import ElementTree
import requests import requests
import vat_moss.id
from django.conf import settings from django.conf import settings
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from zeep import Client, Transport from zeep import Client, Transport
@@ -42,14 +41,142 @@ logger = logging.getLogger(__name__)
error_messages = { error_messages = {
'unavailable': _( 'unavailable': _(
'Your VAT ID could not be checked, as the VAT checking service of ' 'Your VAT ID could not be checked, as the VAT checking service of '
'your country is currently not available. We will therefore ' 'your country is currently not available. We will therefore need to '
'need to charge VAT on your invoice. You can get the tax amount ' 'charge you the same tax rate as if you did not enter a VAT ID.'
'back via the VAT reimbursement process.'
), ),
'invalid': _('This VAT ID is not valid. Please re-check your input.'), 'invalid': _('This VAT ID is not valid. Please re-check your input.'),
'country_mismatch': _('Your VAT ID does not match the selected country.'), '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): class VATIDError(Exception):
def __init__(self, message): def __init__(self, message):
@@ -64,13 +191,57 @@ class VATIDTemporaryError(VATIDError):
pass 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 <will@wbond.net>
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): def _validate_vat_id_NO(vat_id, country_code):
# Inspired by vat_moss library # 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: try:
vat_id = vat_moss.id.normalize(vat_id) vat_id = normalize_vat_id(vat_id, country_code)
except ValueError: except ValueError:
raise VATIDFinalError(error_messages['invalid']) 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): def _validate_vat_id_EU(vat_id, country_code):
# Inspired by vat_moss library # Inspired by vat_moss library
try: try:
vat_id = vat_moss.id.normalize(vat_id) vat_id = normalize_vat_id(vat_id, country_code)
except ValueError: except ValueError:
raise VATIDFinalError(error_messages['invalid']) raise VATIDFinalError(error_messages['invalid'])
@@ -112,11 +283,10 @@ def _validate_vat_id_EU(vat_id, country_code):
raise VATIDFinalError(error_messages['invalid']) raise VATIDFinalError(error_messages['invalid'])
number = vat_id[2:] number = vat_id[2:]
if vat_id[:2] != cc_to_vat_prefix(country_code): if vat_id[:2] != cc_to_vat_prefix(country_code):
raise VATIDFinalError(error_messages['country_mismatch']) 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']) raise VATIDFinalError(error_messages['invalid'])
# We are relying on the country code of the normalized VAT-ID and not the user/InvoiceAddress-provided # 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): def _validate_vat_id_CH(vat_id, country_code):
if vat_id[:3] != 'CHE': 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: try:
transport = Transport( transport = Transport(
cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")), cache=SqliteCache(os.path.join(settings.CACHE_DIR, "validate_vat_id_ch_zeep_cache.db")),

View File

@@ -629,13 +629,40 @@ DEFAULTS = {
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Ask for VAT ID"), label=_("Ask for VAT ID"),
help_text=format_lazy( help_text=format_lazy(
_("Only works if an invoice address is asked for. VAT ID is never required and only requested from " _("Only works if an invoice address is asked for. VAT ID is only requested from business customers "
"business customers in the following countries: {countries}"), "in the following countries: {countries}."),
countries=lazy(lambda *args: ', '.join(sorted(gettext(Country(cc).name) for cc in VAT_ID_COUNTRIES)), str)() 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'}), 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': { 'invoice_address_explanation_text': {
'default': '', 'default': '',
'type': LazyI18nString, 'type': LazyI18nString,

View File

@@ -22,7 +22,7 @@
import pycountry import pycountry
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404 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_countries.fields import Country
from django_scopes import scope from django_scopes import scope
@@ -36,6 +36,22 @@ from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, COUNTRY_STATE_LABEL, 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): def _info(cc):
info = { info = {
@@ -47,7 +63,12 @@ def _info(cc):
'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False, 'required': 'if_any' if cc in COUNTRIES_WITH_STATE_IN_ADDRESS else False,
'label': COUNTRY_STATE_LABEL.get(cc, pgettext('address', 'State')), '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: if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return {'data': [], **info} return {'data': [], **info}
@@ -124,4 +145,10 @@ def address_form(request):
"required": transmission_type.identifier == selected_transmission_type and k in required "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) return JsonResponse(info)

View File

@@ -927,6 +927,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_address_asked', 'invoice_address_asked',
'invoice_address_required', 'invoice_address_required',
'invoice_address_vatid', 'invoice_address_vatid',
'invoice_address_vatid_required_countries',
'invoice_address_company_required', 'invoice_address_company_required',
'invoice_address_beneficiary', 'invoice_address_beneficiary',
'invoice_address_custom_field', 'invoice_address_custom_field',

View File

@@ -43,6 +43,7 @@
{% bootstrap_field form.invoice_name_required layout="control" %} {% bootstrap_field form.invoice_name_required layout="control" %}
{% bootstrap_field form.invoice_address_company_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 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_beneficiary layout="control" %}
{% bootstrap_field form.invoice_address_not_asked_free layout="control" %} {% bootstrap_field form.invoice_address_not_asked_free layout="control" %}
{% bootstrap_field form.invoice_address_custom_field layout="control" %} {% bootstrap_field form.invoice_address_custom_field layout="control" %}

View File

@@ -82,6 +82,9 @@ $(function () {
if ('label' in options) { if ('label' in options) {
dependent.closest(".form-group").find(".control-label").text(options.label); 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 && ( const required = 'required' in options && visible && (
(options.required === 'if_any' && isAnyRequired) || (options.required === 'if_any' && isAnyRequired) ||

View File

@@ -34,7 +34,7 @@ def test_no_invoice_address(client):
'data': [], 'data': [],
'state': {'label': 'State', 'required': False, 'visible': False}, 'state': {'label': 'State', 'required': False, 'visible': False},
'street': {'required': 'if_any'}, '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'} 'zipcode': {'required': 'if_any'}
} }
@@ -44,7 +44,7 @@ def test_no_invoice_address(client):
'data': [], 'data': [],
'state': {'label': 'State', 'required': False, 'visible': False}, 'state': {'label': 'State', 'required': False, 'visible': False},
'street': {'required': 'if_any'}, '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} 'zipcode': {'required': False}
} }
@@ -98,7 +98,7 @@ def test_provider_only_email_available(client, event):
'transmission_peppol_participant_id': {'required': False, 'visible': False}, 'transmission_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': False}, 'transmission_type': {'visible': False},
'transmission_types': [{'code': 'email', 'name': 'Email'}], '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'} '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_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': True}, 'transmission_type': {'visible': True},
'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}], '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'} '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_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': True}, 'transmission_type': {'visible': True},
'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}], '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} '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_peppol_participant_id': {'required': False, 'visible': False},
'transmission_type': {'visible': True}, 'transmission_type': {'visible': True},
'transmission_types': [{'code': 'it_sdi', 'name': 'Exchange System (SdI)'}], '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} '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 @pytest.mark.django_db
def test_email_peppol_choice(client, event): def test_email_peppol_choice(client, event):
response = client.get( response = client.get(
@@ -203,7 +229,7 @@ def test_email_peppol_choice(client, event):
{'code': 'email', 'name': 'Email'}, {'code': 'email', 'name': 'Email'},
{'code': 'peppol', 'name': 'Peppol'}, {'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'} 'zipcode': {'required': 'if_any'}
} }
@@ -229,6 +255,6 @@ def test_email_peppol_choice(client, event):
{'code': 'email', 'name': 'Email'}, {'code': 'email', 'name': 'Email'},
{'code': 'peppol', 'name': 'Peppol'}, {'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} 'zipcode': {'required': True}
} }

View File

@@ -24,7 +24,7 @@ import responses
from requests import Timeout from requests import Timeout
from pretix.base.services.tax import ( 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') 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 @responses.activate
def test_eu_server_down(): def test_eu_server_down():
def _callback(request): def _callback(request):

View File

@@ -411,6 +411,69 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
with scopes_disabled(): with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=self.client.session['carts'][self.session_key].get('invoice_address')) 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 assert not ia.vat_id_validated
def test_reverse_charge_keep_gross(self): def test_reverse_charge_keep_gross(self):
@@ -448,6 +511,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
with scopes_disabled(): with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=self.client.session['carts'][self.session_key].get('invoice_address')) 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 assert ia.vat_id_validated
def test_custom_tax_rules(self): def test_custom_tax_rules(self):
@@ -1452,7 +1516,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'transmission_type': 'it_sdi', 'transmission_type': 'it_sdi',
'vat_id': '', 'vat_id': '',
}, follow=True) }, 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), { response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'is_business': 'business', 'is_business': 'business',
@@ -1468,6 +1532,7 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
'state': 'MI', 'state': 'MI',
'email': 'admin@localhost', 'email': 'admin@localhost',
'transmission_type': 'email', 'transmission_type': 'email',
'vat_id': 'IT01234567890',
}, follow=True) }, follow=True)
assert "must be used for this country" in response.content.decode() assert "must be used for this country" in response.content.decode()