[IMP] base_vat: VIES validation on Fiscal Position

At present the user can select an option in the settings to check VAT
numbers against the VIES system. If the VAT number fails this
validation the result is a non-blocking banner message that informs the
user that the VIES validation has failed, but has no further
ramifications (the user can still use an unrecognised VAT number).

This non-blocking functionality is still desirable, however we wish to
determine the validity of certain fiscal positions based on whether the
VIES VAT check is valid.

In order to acheive this, the computed boolean `vies_valid` field is
added, and populated based on the results when comparing the VAT against
the VIES system. It depends on the `vat` and `country_id` of the
partner. If it looks like the VIES check needs to be performed on this
vat and if any company in the db requires a VIES vat check, the check is
performed, and if none do, then check is not performed. The field can be
manually edited, but is also tracked.

Provided we know whether a partner has a valid VIES vat or not, it is
only important sometimes in trying to find the appropriate fiscal
position (because VIES is only confirms validity of a VAT number for
intra-community trade). Because of this, a computed boolean field
called `perform_vies_validation` is added to represent this on the
partner. For example if a partner is from the same country as the
current company, then it doesn't matter that it's VIES valid or not, all
that matters is that there is some string in its vat field for the "VAT
Required" to be satsified, so the `perform_vies_validation` field would
be False. This field is also used to determine whether the `vies_valid`
checkbox should be shown or hidden on the partner form view.

A hook called _get_vat_valid is placed in the method on fiscal position
that retrieves the appropriate fiscal position for a given account move,
and it is overridden by a function in base_vat, which specifies whether
the partner/delivery address matches the 'vat_required' condition when
VIES validity is relevant for the company/partner (see the above
`perform_vies_validation` field).

`sale_stock` and `test_mail` performance tests are updated in order to
account for the additional queries introduced in the _get_vat_required
hook (in fetching the base.europe country ids) and the
_compute_vies_valid respectively.

closes odoo/odoo#116391

Task-id: 3218194
Related: odoo/upgrade#4498
Signed-off-by: William André (wan) <wan@odoo.com>
This commit is contained in:
Daniel Kosky (dako)
2023-05-17 14:47:01 +02:00
parent a0b6b051ae
commit b1f1f216d0
6 changed files with 100 additions and 78 deletions
+7 -3
View File
@@ -209,6 +209,10 @@ class AccountFiscalPosition(models.Model):
fpos = self.search(base_domain + null_country_dom, limit=1)
return fpos
def _get_vat_valid(self, delivery, company=None):
""" Hook for determining VAT validity with more complex VAT requirements """
return bool(delivery.vat)
@api.model
def _get_fiscal_position(self, partner, delivery=None):
"""
@@ -235,11 +239,11 @@ class AccountFiscalPosition(models.Model):
return manual_fiscal_position
# First search only matching VAT positions
vat_required = bool(partner.vat)
fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, vat_required)
vat_valid = self._get_vat_valid(delivery, company)
fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, vat_valid)
# Then if VAT required found no match, try positions that do not require it
if not fp and vat_required:
if not fp and vat_valid:
fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, False)
return fp or self.env['account.fiscal.position']
@@ -22,3 +22,21 @@ class AccountFiscalPosition(models.Model):
fp_label = _("fiscal position [%s]", record.name)
error_message = self.env['res.partner']._build_vat_error_message(record.country_id.code.lower(), record.foreign_vat, fp_label)
raise ValidationError(error_message)
def _get_vat_valid(self, delivery, company=None):
eu_countries = self.env.ref('base.europe').country_ids
# If VIES validation does not apply to this partner (e.g. they
# are in the same country as the partner), then skip.
if not (company and delivery.with_company(company).perform_vies_validation):
return super()._get_vat_valid(delivery, company)
# If the company has a fiscal position with a foreign vat in Europe, in the same country as the partner, then the VIES validity applies
if self.search_count([
('foreign_vat', '!=', False),
('country_id', '=', delivery.country_id.id),
('company_id', '=', company.id)
]) or company.country_id in eu_countries:
return super()._get_vat_valid(delivery, company) and delivery.vies_valid
return super()._get_vat_valid(delivery, company)
+61 -65
View File
@@ -87,7 +87,16 @@ _region_specific_vat_codes = {
class ResPartner(models.Model):
_inherit = 'res.partner'
vies_failed_message = fields.Char('Technical field display a message to the user if the VIES check fails.', store=False)
vies_valid = fields.Boolean(
string="Intra-Community Valid",
compute='_compute_vies_valid', store=True, readonly=False,
tracking=True,
help='European VAT numbers are automatically checked on the VIES database.',
)
# Field representing whether vies_valid is relevant for selecting a fiscal position on this partner
perform_vies_validation = fields.Boolean(compute='_compute_perform_vies_validation')
# Technical field used to determine the VAT to check
vies_vat_to_check = fields.Char(compute='_compute_vies_vat_to_check')
def _split_vat(self, vat):
vat_country, vat_number = vat[:2].lower(), vat[2:].replace(' ', '')
@@ -113,30 +122,37 @@ class ResPartner(models.Model):
return bool(self.env['res.country'].search([('code', '=ilike', country_code)]))
return check_func(vat_number)
@api.model
@tools.ormcache('vat')
def _check_vies(self, vat):
# Store the VIES result in the cache. In case an exception is raised during the request
# (e.g. service unavailable), the fallback on simple_vat_check is not kept in cache.
return check_vies(vat)
@api.depends('vat', 'country_id')
def _compute_vies_vat_to_check(self):
""" Retrieve the VAT number, if one such exists, to be used when checking against the VIES system """
eu_country_codes = self.env.ref('base.europe').country_ids.mapped('code')
for partner in self:
# Skip checks when only one character is used. Some users like to put '/' or other as VAT to differentiate between
# a partner for which they haven't yet input VAT, and one not subject to VAT
if not partner.vat or len(partner.vat) == 1:
partner.vies_vat_to_check = ''
continue
country_code, number = partner._split_vat(partner.vat)
if not country_code.isalpha() and partner.country_id:
country_code = partner.country_id.code
number = partner.vat
partner.vies_vat_to_check = (
country_code.upper() in eu_country_codes or
country_code.lower() in _region_specific_vat_codes
) and self._fix_vat_number(country_code + number, partner.country_id.id) or ''
@api.model
def vies_vat_check(self, country_code, vat_number):
try:
# Validate against VAT Information Exchange System (VIES)
# see also http://ec.europa.eu/taxation_customs/vies/
vies_result = self._check_vies(country_code.upper() + vat_number)
return vies_result['valid']
except InvalidComponent:
return False
except Exception:
# see http://ec.europa.eu/taxation_customs/vies/checkVatService.wsdl
# Fault code may contain INVALID_INPUT, SERVICE_UNAVAILABLE, MS_UNAVAILABLE,
# TIMEOUT or SERVER_BUSY. There is no way we can validate the input
# with VIES if any of these arise, including the first one (it means invalid
# country code or empty VAT number), so we return True and ignore the result.
_logger.exception("Failed VIES VAT check.")
return True
@api.depends_context('company')
@api.depends('vies_vat_to_check')
def _compute_perform_vies_validation(self):
""" Determine whether to show VIES validity on the current VAT number """
for partner in self:
to_check = partner.vies_vat_to_check
company_code = self.env.company.account_fiscal_country_id.code
partner.perform_vies_validation = (
to_check
and not to_check[:2].upper() == company_code
and self.env.company.vat_check_vies
)
@api.model
def fix_eu_vat_number(self, country_id, vat):
@@ -169,27 +185,30 @@ class ResPartner(models.Model):
msg = partner._build_vat_error_message(country and country.code.lower() or None, partner.vat, partner_label)
raise ValidationError(msg)
@api.onchange('vat', 'country_id')
def _onchange_check_vies(self):
""" Check the VAT number with VIES, if enabled. Return a non-blocking warning if the check fails."""
if self.env.context.get('company_id'):
company = self.env['res.company'].browse(self.env.context['company_id'])
else:
company = self.env.company
if not company.vat_check_vies:
@api.depends('vies_vat_to_check')
def _compute_vies_valid(self):
""" Check the VAT number with VIES, if enabled."""
if not self.env['res.company'].sudo().search_count([('vat_check_vies', '=', True)]):
self.vies_valid = False
return
eu_countries = self.env.ref('base.europe').country_ids
for eu_partner_company in self.filtered(lambda partner: partner.country_id in eu_countries and partner.is_company):
# Skip checks when only one character is used. Some users like to put '/' or other as VAT to differentiate between
# A partner for which they didn't input VAT, and the one not subject to VAT
if not eu_partner_company.vat or len(eu_partner_company.vat) == 1:
for partner in self:
if not partner.vies_vat_to_check:
partner.vies_valid = False
continue
country = eu_partner_company.country_id
if self._run_vies_test(eu_partner_company.vat, country) is False:
self.vies_failed_message = _("The VAT number %s failed the VIES VAT validation check.", eu_partner_company.vat)
else:
self.vies_failed_message = False
try:
vies_valid = check_vies(partner.vies_vat_to_check, timeout=10)
partner.vies_valid = vies_valid['valid']
except Exception as e:
if partner._origin.id:
msg = ""
if isinstance(e, OSError):
msg = _("Connection with the VIES server failed. The VAT number %s could not be validated.", partner.vies_vat_to_check)
elif isinstance(e, InvalidComponent):
msg = _("The VAT number %s could not be interpreted by the VIES server.", partner.vies_vat_to_check)
partner._origin.message_post(body=msg)
_logger.exception("The VAT number %s failed VIES check.", partner.vies_vat_to_check)
partner.vies_valid = False
@api.model
def _run_vat_test(self, vat_number, default_country, partner_is_company=True):
@@ -218,29 +237,6 @@ class ResPartner(models.Model):
# If vat is set before country_id, the constraint must not break.
return check_result
@api.model
def _run_vies_test(self, vat_number, default_country):
""" Validate a VAT number using the VIES VAT validation. """
check_result = None
# First check with country code as prefix of the TIN
vat_country_code, vat_number_split = self._split_vat(vat_number)
vat_has_legit_country_code = self.env['res.country'].search([('code', '=', vat_country_code.upper())])
if not vat_has_legit_country_code:
vat_has_legit_country_code = vat_country_code.lower() in _region_specific_vat_codes
if vat_has_legit_country_code:
check_result = self.vies_vat_check(vat_country_code, vat_number_split)
if check_result:
return vat_country_code
# If it fails, check with default_country (if it exists)
if default_country:
check_result = self.vies_vat_check(default_country.code.lower(), vat_number)
if check_result:
return default_country.code.lower()
return check_result
@api.model
def _build_vat_error_message(self, country_code, wrong_vat, record_label):
# OVERRIDE account
+7 -3
View File
@@ -6,9 +6,13 @@
<field name="name">view.partner.base.vat.form</field>
<field name="inherit_id" ref="base.view_partner_form" />
<field name="arch" type="xml">
<xpath expr="//div[hasclass('alert')]" position="after">
<div class="alert alert-warning oe_edit_only" role="alert" attrs="{'invisible': [('vies_failed_message', '=', False)]}">
<field name="vies_failed_message" nolabel="1" readonly="1"/>
<xpath expr="//field[@name='vat']" position="replace">
<field name="perform_vies_validation" invisible="1"/>
<label for="vat" string="Tax ID"/>
<div>
<field name="vat" placeholder="e.g. BE0477472701" attrs="{'readonly': [('parent_id','!=',False)]}" class="oe_inline"/>
<label for="vies_valid" attrs="{'invisible': [('perform_vies_validation', '=', False)]}"/>
<field name="vies_valid" attrs="{'invisible': [('perform_vies_validation', '=', False)]}"/>
</div>
</xpath>
</field>
+3 -3
View File
@@ -50,7 +50,7 @@ class TestPERF(common.TransactionCase):
@warmup
@prepare
def test_empty_sale_order_creation_perf(self):
with self.assertQueryCount(admin=31):
with self.assertQueryCount(admin=32):
self.env['sale.order'].create({
'partner_id': self.partners[0].id,
'user_id': self.salesmans[0].id,
@@ -65,7 +65,7 @@ class TestPERF(common.TransactionCase):
# + 1 warehouse fetch
# + 1 query to get analytic default account
# + 1 followers queries ?
with self.assertQueryCount(admin=35):
with self.assertQueryCount(admin=36):
self.env['sale.order'].create([{
'partner_id': self.partners[0].id,
'user_id': self.salesmans[0].id,
@@ -77,7 +77,7 @@ class TestPERF(common.TransactionCase):
def test_dummy_sales_orders_batch_creation_perf(self):
""" Dummy SOlines (notes/sections) should not add any custom queries other than their insert"""
# + 2 SOL (batched) insert
with self.assertQueryCount(admin=37):
with self.assertQueryCount(admin=39):
self.env['sale.order'].create([{
'partner_id': self.partners[0].id,
'user_id': self.salesmans[0].id,
+4 -4
View File
@@ -455,7 +455,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
test_record, test_template = self._create_test_records()
test_template.write({'attachment_ids': [(5, 0)]})
with self.assertQueryCount(admin=25, employee=25): # tm 15/15 / com 24/24
with self.assertQueryCount(admin=26, employee=26): # tm 15/15 / com 24/24
composer = self.env['mail.compose.message'].with_context({
'default_composition_mode': 'comment',
'default_model': test_record._name,
@@ -479,7 +479,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
def test_mail_composer_w_template_attachments(self):
test_record, test_template = self._create_test_records()
with self.assertQueryCount(admin=25, employee=25): # tm 15/15 / com 24/24
with self.assertQueryCount(admin=26, employee=26): # tm 15/15 / com 24/24
composer = self.env['mail.compose.message'].with_context({
'default_composition_mode': 'comment',
'default_model': test_record._name,
@@ -508,7 +508,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
test_template.write({'attachment_ids': [(5, 0)]})
customer = self.env['res.partner'].browse(self.customer.ids)
with self.assertQueryCount(admin=38, employee=38): # tm 28/28 / com 37/37
with self.assertQueryCount(admin=39, employee=39): # tm 28/28 / com 37/37
composer_form = Form(
self.env['mail.compose.message'].with_context({
'default_composition_mode': 'comment',
@@ -538,7 +538,7 @@ class TestMailAPIPerformance(BaseMailPerformance):
test_record, test_template = self._create_test_records()
customer = self.env['res.partner'].browse(self.customer.ids)
with self.assertQueryCount(admin=37, employee=37): # tm 27/27 / com 36/36
with self.assertQueryCount(admin=38, employee=38): # tm 27/27 / com 36/36
composer_form = Form(
self.env['mail.compose.message'].with_context({
'default_composition_mode': 'comment',