diff --git a/modules/party/CHANGELOG b/modules/party/CHANGELOG index ce925ce916..8e9f21555f 100644 --- a/modules/party/CHANGELOG +++ b/modules/party/CHANGELOG @@ -1,3 +1,4 @@ +* Replace the VIES wizard by an automatic background task * Guess the type of contact mechanism Version 8.0.0 - 2026-04-20 diff --git a/modules/party/configuration.py b/modules/party/configuration.py index 3a9ebefb9c..cca9bb3b9b 100644 --- a/modules/party/configuration.py +++ b/modules/party/configuration.py @@ -1,5 +1,8 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. + +import datetime as dt + from trytond.i18n import gettext from trytond.model import ( ModelSingleton, ModelSQL, ModelView, MultiValueMixin, ValueMixin, fields) @@ -28,6 +31,9 @@ class Configuration(ModelSingleton, ModelSQL, ModelView, MultiValueMixin): IDENTIFIER_TYPES, "Identifier Types", sort=False, help="Defines which identifier types are available.\n" "Leave empty for all of them.") + identifier_eu_vat_validation_period = fields.TimeDelta( + "European VAT Number Validation Period", required=True, + help="The period during which the number is still considered valid.") @classmethod def __register__(cls, module): @@ -55,6 +61,10 @@ class Configuration(ModelSingleton, ModelSQL, ModelView, MultiValueMixin): except KeyError: return None + @classmethod + def default_identifier_eu_vat_validation_period(cls): + return dt.timedelta(days=365) + def get_identifier_types(self): selection = self.fields_get( ['identifier_types'])['identifier_types']['selection'] diff --git a/modules/party/doc/design.rst b/modules/party/doc/design.rst index 2b1e3f5651..77e2a5aaff 100644 --- a/modules/party/doc/design.rst +++ b/modules/party/doc/design.rst @@ -43,17 +43,6 @@ stuck onto envelopes. Wizards ------- -.. _wizard-party.check_vies: - -Check VIES -^^^^^^^^^^ - -The *Check* :abbr:`VIES (VAT Information Exchange System)` wizard uses the -European Commission's `VIES web service`_ to verify that a party's -VAT-identification number is valid. - -.. _VIES web service: https://ec.europa.eu/taxation_customs/vies/ - .. _wizard-party.replace: Replace @@ -90,6 +79,10 @@ They include things like company, personal identification, social security, tax and vat registration numbers. Most types of identifiers are checked by Tryton before they get saved to ensure that they are valid. +The :guilabel:`European VAT Number`'s are also checked automatically against +the European Commission's `VIES web service`_. + +.. _VIES web service: https://ec.europa.eu/taxation_customs/vies/ .. _model-party.address: diff --git a/modules/party/doc/usage.rst b/modules/party/doc/usage.rst index eb297e83dd..d7807850c6 100644 --- a/modules/party/doc/usage.rst +++ b/modules/party/doc/usage.rst @@ -78,15 +78,6 @@ that new parties are given a language. This, in turn, can be important as Tryton will automatically generate reports, such as invoices and delivery notes, in the correct language for each party. -.. _Checking VAT numbers are valid: - -Checking VAT numbers are valid -============================== - -If the `Party ` is in the European Union you can use the -`Check VIES ` on it to check whether its VAT number -is valid or not. - .. _Merging duplicate parties together: Merging duplicate parties together diff --git a/modules/party/ir.py b/modules/party/ir.py index 8e34c423de..da7de79692 100644 --- a/modules/party/ir.py +++ b/modules/party/ir.py @@ -6,6 +6,21 @@ from trytond.tools import escape_wildcard, grouped_slice from trytond.transaction import Transaction +class Cron(metaclass=PoolMeta): + __name__ = 'ir.cron' + + @classmethod + def __setup__(cls): + super().__setup__() + cls.method.selection.extend([ + ('party.identifier|check_vies', + "Check European VAT Number with VIES"), + ]) + cls._notifications.update({ + 'party.identifier|check_vies', + }) + + class Email(metaclass=PoolMeta): __name__ = 'ir.email' diff --git a/modules/party/message.xml b/modules/party/message.xml index 06cc6371c0..bab274a539 100644 --- a/modules/party/message.xml +++ b/modules/party/message.xml @@ -30,6 +30,9 @@ this repository contains the full copyright notices and license terms. --> The VIES service is unavailable, try again later. + + The VAT numbers "%(identifiers)s" are not valid for the VIES service. + Parties have different names: "%(source_name)s" vs "%(destination_name)s". diff --git a/modules/party/party.py b/modules/party/party.py index 138bfd663e..a177bc0707 100644 --- a/modules/party/party.py +++ b/modules/party/party.py @@ -1,12 +1,13 @@ # This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. +import datetime as dt import logging import re from collections import defaultdict import stdnum.exceptions -from sql import Column, Literal +from sql import Column, Literal, Null from sql.aggregate import Min from sql.functions import CharLength from stdnum import get_cc_module @@ -19,7 +20,7 @@ from trytond.model import ( ValueMixin, convert_from, fields, sequence_ordered) from trytond.model.exceptions import AccessError from trytond.pool import Pool -from trytond.pyson import Bool, Eval +from trytond.pyson import Bool, Eval, If, PYSONEncoder from trytond.tools import is_full_text, lstrip_wildcard from trytond.transaction import Transaction, inactive_records from trytond.wizard import Button, StateTransition, StateView, Wizard @@ -847,13 +848,47 @@ class Identifier(sequence_ordered(), DeactivableMixin, ModelSQL, ModelView): fields.Boolean("Type of Address"), 'on_change_with_type_address') code = fields.Char('Code', required=True) code_compact = fields.Char("Code Compact", readonly=True, required=True) + eu_vat_valid = fields.Boolean( + "European VAT Number Valid", readonly=True, + states={ + 'invisible': Eval('type') != 'eu_vat', + }, + help="Checked if the number has been validated on VIES service.") + eu_vat_validated_at = fields.DateTime( + "European VAT Number Validated At", readonly=True, + states={ + 'invisible': Eval('type') != 'eu_vat', + }) @classmethod def __setup__(cls): cls.code.search_unaccented = False cls.code_compact.search_unaccented = False super().__setup__() + t = cls.__table__() + cls._sql_indexes.update({ + Index( + t, + (t.active, Index.Equality(cardinality='low')), + (t.type, Index.Equality(cardinality='low')), + (t.eu_vat_valid, Index.Equality(cardinality='low')), + where=(t.type == 'eu_vat') + & ((t.eu_vat_valid == Literal(False)) + | (t.eu_vat_valid == Null)) + & (t.active == Literal(True))), + Index( + t, + (t.active, Index.Equality(cardinality='low')), + (t.type, Index.Equality(cardinality='low')), + (t.eu_vat_validated_at, Index.Range()), + where=(t.type == 'eu_vat') + & (t.active == Literal(True))), + }) cls.__access__.add('party') + cls._buttons.update( + check_vies_button={ + 'invisible': Eval('type') != 'eu_vat', + }) @classmethod def __register__(cls, module_name): @@ -965,8 +1000,21 @@ class Identifier(sequence_ordered(), DeactivableMixin, ModelSQL, ModelView): values['code_compact'] = module.compact(code) except stdnum.exceptions.ValidationError: pass + if mode == 'write': + if {'type', 'code'} & values.keys(): + values['eu_vat_valid'] = None + values['eu_vat_validated_at'] = None return values + @classmethod + def on_modification(cls, mode, identifiers, field_names=None): + super().on_modification(mode, identifiers, field_names=field_names) + if (mode == 'create' + or (mode == 'write' and ( + field_names is None + or {'type', 'code'} & field_names))): + cls.__queue__.check_vies(identifiers) + def compute_fields(self, field_names=None): values = super().compute_fields(field_names=field_names) if field_names is None or {'type', 'code'} & field_names: @@ -1015,68 +1063,107 @@ class Identifier(sequence_ordered(), DeactivableMixin, ModelSQL, ModelView): type=other.type_string, code=other.code)) + @classmethod + def copy(cls, identifiers, default=None): + default = default.copy() if default is not None else {} + default.setdefault('eu_vat_valid') + default.setdefault('eu_vat_validated_at') + return super().copy(identifiers, default=default) -class CheckVIESResult(ModelView): - __name__ = 'party.check_vies.result' - parties_succeed = fields.Many2Many('party.party', None, None, - 'Parties Succeed', readonly=True, states={ - 'invisible': ~Eval('parties_succeed'), - }) - parties_failed = fields.Many2Many('party.party', None, None, - 'Parties Failed', readonly=True, states={ - 'invisible': ~Eval('parties_failed'), - }) + @classmethod + def view_attributes(cls): + return super().view_attributes() + [ + ('/tree', 'visual', + If((Eval('type') == 'eu_vat') + & ~Eval('eu_vat_valid'), + If(Eval('eu_vat_validated_at'), + 'danger', + 'warning'), + '')), + ] + @classmethod + @ModelView.button + def check_vies_button(cls, identifiers): + cls.check_vies(identifiers) -class CheckVIES(Wizard): - __name__ = 'party.check_vies' - start_state = 'check' + @classmethod + def check_vies(cls, identifiers=None): + pool = Pool() + Configuration = pool.get('party.configuration') + Cron = pool.get('ir.cron') - check = StateTransition() - result = StateView('party.check_vies.result', - 'party.check_vies_result', [ - Button('OK', 'end', 'tryton-ok', True), - ]) - - def transition_check(self): - parties_succeed = [] - parties_failed = [] - for party in self.records: - for identifier in party.identifiers: - if identifier.type != 'eu_vat': - continue - eu_vat = get_cc_module('eu', 'vat') - try: - if not eu_vat.check_vies(identifier.code)['valid']: - parties_failed.append(party.id) - else: - parties_succeed.append(party.id) - except Exception as e: - for msg in e.args: - if msg == 'INVALID_INPUT': - parties_failed.append(party.id) - break - elif msg in { - 'SERVICE_UNAVAILABLE', - 'MS_UNAVAILABLE', - 'MS_MAX_CONCURRENT_REQ', - 'GLOBAL_MS_MAX_CONCURRENT_REQ', - 'TIMEOUT', - 'SERVER_BUSY', - }: - raise VIESUnavailable( - gettext('party.msg_vies_unavailable')) from e - else: - raise - self.result.parties_succeed = parties_succeed - self.result.parties_failed = parties_failed - return 'result' - - def default_result(self, fields): - return { - 'parties_succeed': [p.id for p in self.result.parties_succeed], - 'parties_failed': [p.id for p in self.result.parties_failed], - } + config = Configuration(1) + now = dt.datetime.now() + if identifiers is None: + identifiers = cls.search([ + ('type', '=', 'eu_vat'), + ['OR', + ('eu_vat_valid', '=', False), + ('eu_vat_validated_at', '<', + now - config.identifier_eu_vat_validation_period), + ], + ], + order=[]) + if not identifiers: + return + failed, succeeded = [], [] + for identifier in identifiers: + if identifier.type != 'eu_vat': + continue + eu_vat = get_cc_module('eu', 'vat') + try: + if not eu_vat.check_vies(identifier.code)['valid']: + failed.append(identifier) + else: + succeeded.append(identifier) + except ImportError: + # Missing dependencies so we consider the identifier as valid + succeeded.append(identifier) + except Exception as e: + for msg in e.args: + if msg == 'INVALID_INPUT': + failed.append(identifier) + break + elif msg in { + 'SERVICE_UNAVAILABLE', + 'MS_UNAVAILABLE', + 'MS_MAX_CONCURRENT_REQ', + 'GLOBAL_MS_MAX_CONCURRENT_REQ', + 'TIMEOUT', + 'SERVER_BUSY', + }: + raise VIESUnavailable( + gettext('party.msg_vies_unavailable')) from e + else: + raise + cls.write(succeeded, { + 'eu_vat_valid': True, + 'eu_vat_validated_at': now, + }) + if failed: + encoder = PYSONEncoder() + cls.write(failed, { + 'eu_vat_valid': False, + 'eu_vat_validated_at': now, + }) + names = ', '.join(i.rec_name for i in failed[:5]) + if len(failed) < 5: + domain = [('id', 'in', [i.id for i in failed])] + else: + names += '...' + domain = [ + ('type', '=', 'eu_vat'), + ('eu_vat_valid', '=', False), + ] + Cron.notify( + 'tryton-error', + 'party.act_identifier_form', { + 'pyson_domain': encoder.encode(domain), + }, + 'party.msg_party_identifier_eu_vat_invalid', + identifiers=names, + ) class Replace(Wizard): diff --git a/modules/party/party.xml b/modules/party/party.xml index e01cf2c8aa..2f7e978a34 100644 --- a/modules/party/party.xml +++ b/modules/party/party.xml @@ -147,21 +147,10 @@ this repository contains the full copyright notices and license terms. --> sequence="20" id="menu_identifier_form"/> - - Check VIES - party.check_vies - party.party - - - form_action - party.party,-1 - - - - - party.check_vies.result - form - check_vies_result + + party.identifier + check_vies_button + Check VIES @@ -207,4 +196,11 @@ this repository contains the full copyright notices and license terms. --> erase_ask_form + + + party.identifier|check_vies + + days + + diff --git a/modules/party/tryton.cfg b/modules/party/tryton.cfg index 80e54cf1e1..22c873a930 100644 --- a/modules/party/tryton.cfg +++ b/modules/party/tryton.cfg @@ -15,13 +15,13 @@ xml: [register] model: + ir.Cron country.PostalCode category.Category party.Party party.PartyLang party.PartyCategory party.Identifier - party.CheckVIESResult party.ReplaceAsk party.EraseAsk address.Address @@ -36,6 +36,5 @@ model: ir.EmailTemplate ir.Channel wizard: - party.CheckVIES party.Replace party.Erase diff --git a/modules/party/view/check_vies_result.xml b/modules/party/view/check_vies_result.xml deleted file mode 100644 index 86564949b9..0000000000 --- a/modules/party/view/check_vies_result.xml +++ /dev/null @@ -1,7 +0,0 @@ - - -
- - - diff --git a/modules/party/view/configuration_form.xml b/modules/party/view/configuration_form.xml index 056a238f3d..e1c6fbe53c 100644 --- a/modules/party/view/configuration_form.xml +++ b/modules/party/view/configuration_form.xml @@ -9,4 +9,6 @@ this repository contains the full copyright notices and license terms. --> +