diff --git a/addons/account_edi_ubl_cii/models/account_edi_common.py b/addons/account_edi_ubl_cii/models/account_edi_common.py index 34601565af8..c3b2125badb 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_common.py +++ b/addons/account_edi_ubl_cii/models/account_edi_common.py @@ -46,7 +46,7 @@ EAS_MAPPING = { 'AT': {'9915': 'vat'}, 'AU': {'0151': 'vat'}, 'BA': {'9924': 'vat'}, - 'BE': {'9925': 'vat'}, + 'BE': {'9925': 'vat', '0208': 'company_registry'}, 'BG': {'9926': 'vat'}, 'CH': {'9927': 'vat'}, 'CY': {'9928': 'vat'}, diff --git a/addons/account_peppol/__manifest__.py b/addons/account_peppol/__manifest__.py index 1a7972b0517..776a87d302f 100644 --- a/addons/account_peppol/__manifest__.py +++ b/addons/account_peppol/__manifest__.py @@ -24,4 +24,9 @@ 'wizard/account_move_send_views.xml', ], 'license': 'LGPL-3', + 'assets': { + 'web.assets_backend': [ + 'account_peppol/static/src/components/**/*', + ], + } } diff --git a/addons/account_peppol/models/account_edi_proxy_user.py b/addons/account_peppol/models/account_edi_proxy_user.py index 5c9b6dc8902..1e54d8e0e1e 100644 --- a/addons/account_peppol/models/account_edi_proxy_user.py +++ b/addons/account_peppol/models/account_edi_proxy_user.py @@ -3,9 +3,9 @@ import logging -from odoo import fields, models, tools, _ -from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError +from odoo import _, fields, models, tools from odoo.exceptions import UserError +from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError _logger = logging.getLogger(__name__) @@ -13,10 +13,16 @@ _logger = logging.getLogger(__name__) class AccountEdiProxyClientUser(models.Model): _inherit = 'account_edi_proxy_client.user' + peppol_migration_key = fields.Char(string="Migration Key") + peppol_verification_code = fields.Char(string='SMS verification code') proxy_type = fields.Selection( selection_add=[('peppol', 'PEPPOL')], ) + # ------------------------------------------------------------------------- + # HELPER METHODS + # ------------------------------------------------------------------------- + def _get_proxy_urls(self): urls = super()._get_proxy_urls() urls['peppol'] = { @@ -38,6 +44,15 @@ class AccountEdiProxyClientUser(models.Model): return super()._get_server_url(proxy_type, edi_mode) + def _get_proxy_identification(self, company): + if not company.peppol_eas or not company.peppol_endpoint: + raise UserError(_("Please fill in the EAS code and the Participant ID code.")) + return f'{company.peppol_eas}:{company.peppol_endpoint}' + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + def _compute_proxy_type(self): # Extends account_edi_proxy_client super()._compute_proxy_type() @@ -45,11 +60,9 @@ class AccountEdiProxyClientUser(models.Model): if user.company_id.is_account_peppol_participant: user.proxy_type = 'peppol' - def _get_proxy_identification(self, company): - if not company.peppol_eas or not company.peppol_endpoint: - raise UserError( - _("Please fill in the EAS code and the Participant ID code in the company's partner form.")) - return f'{company.peppol_eas}:{company.peppol_endpoint}' + # ------------------------------------------------------------------------- + # BUSINESS ACTIONS + # ------------------------------------------------------------------------- def _cron_peppol_get_new_documents(self): # Retrieve all new Peppol documents for every edi user in the database @@ -136,6 +149,7 @@ class AccountEdiProxyClientUser(models.Model): else: move._message_log( body=_('Peppol document has been received successfully')) + # pylint: disable=broad-except except Exception: # if the invoice creation fails for any reason, # we want to create an empty invoice with the attachment @@ -205,7 +219,7 @@ class AccountEdiProxyClientUser(models.Model): def _cron_peppol_get_participant_status(self): edi_users = self.env['account_edi_proxy_client.user'].search( - [('company_id.account_peppol_proxy_state', 'in', ('pending', 'manually_approved'))]) + [('company_id.account_peppol_proxy_state', '=', 'pending')]) for edi_user in edi_users: try: @@ -215,5 +229,5 @@ class AccountEdiProxyClientUser(models.Model): _logger.error('Error while updating Peppol participant status: %s', e) continue - if proxy_user['peppol_state'] != 'draft': + if proxy_user['peppol_state'] in {'active', 'rejected', 'canceled'}: edi_user.company_id.account_peppol_proxy_state = proxy_user['peppol_state'] diff --git a/addons/account_peppol/models/res_company.py b/addons/account_peppol/models/res_company.py index abca13e50b1..40f9fa50993 100644 --- a/addons/account_peppol/models/res_company.py +++ b/addons/account_peppol/models/res_company.py @@ -1,23 +1,66 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +import re +from stdnum import get_cc_module, ean + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError +from odoo.addons.account_edi_ubl_cii.models.account_edi_common import EAS_MAPPING + +try: + import phonenumbers +except ImportError: + phonenumbers = None + +# at the moment, only phone numbers in European countries are accepted +PHONE_ALLOWED_COUNTRIES = set(EAS_MAPPING.keys()) - {'AU', 'SG', 'NZ'} + +PEPPOL_ENDPOINT_RULES = { + '0007': ['se', 'orgnr'], + '0088': ['ean'], + '0184': ['dk', 'cvr'], + '0192': ['no', 'orgnr'], + '0208': ['be', 'vat'], +} + +PEPPOL_ENDPOINT_WARNING = { + '0201': ['regex', '[0-9a-zA-Z]{6}$'], + '0210': ['it', 'codicefiscale'], + '0211': ['it', 'iva'], + '9906': ['it', 'iva'], + '9907': ['it', 'codicefiscale'], + '0151': ['au', 'abn'], +} class ResCompany(models.Model): _inherit = 'res.company' + # to be removed once the module is available account_peppol_attachment_ids = fields.Many2many( comodel_name='ir.attachment', string='Peppol Identification Documents', ) + account_peppol_contact_email = fields.Char( + string='Primary contact email', + compute='_compute_account_peppol_contact_email', store=True, readonly=False, + help='Primary contact email for Peppol-related communication', + ) + account_peppol_phone_number = fields.Char( + string='Phone number (for validation)', + help='You will receive a verification code to this phone number', + ) account_peppol_proxy_state = fields.Selection( selection=[ ('not_registered', 'Not registered'), + ('not_verified', 'Not verified'), + ('sent_verification', 'Verification code sent'), ('pending', 'Pending'), ('manually_approved', 'Approved'), ('active', 'Active'), ('rejected', 'Rejected'), + ('canceled', 'Canceled'), ], string='PEPPOL status', compute='_compute_account_peppol_proxy_state', required=True, readonly=False, store=True, precompute=True, @@ -32,6 +75,73 @@ class ResCompany(models.Model): compute='_compute_peppol_purchase_journal_id', store=True, readonly=False, ) + # ------------------------------------------------------------------------- + # HELPER METHODS + # ------------------------------------------------------------------------- + + def _sanitize_peppol_phone_number(self): + self.ensure_one() + + error_message = _( + "Please enter the phone number in the correct international format.\n" + "For example: +32123456789, where +32 is the country code.\n" + "Currently, only European countries are supported.") + + if not phonenumbers: + raise ValidationError(error_message) + + phone_number = self.account_peppol_phone_number + if not phone_number.startswith('+'): + phone_number = f'+{phone_number}' + + try: + phone_nbr = phonenumbers.parse(phone_number) + except phonenumbers.phonenumberutil.NumberParseException: + raise ValidationError(error_message) + + country_code = phonenumbers.phonenumberutil.region_code_for_number(phone_nbr) + if country_code not in PHONE_ALLOWED_COUNTRIES or not phonenumbers.is_valid_number(phone_nbr): + raise ValidationError(error_message) + + def _check_peppol_endpoint_number(self, warning=False): + self.ensure_one() + + peppol_dict = PEPPOL_ENDPOINT_WARNING if warning else PEPPOL_ENDPOINT_RULES + endpoint_rule = peppol_dict.get(self.peppol_eas) + if not endpoint_rule: + return True + + if endpoint_rule[0] == 'regex': + return bool(re.match(endpoint_rule[1], self.peppol_endpoint)) + + if endpoint_rule[0] == 'ean': + check_module = ean + else: + check_module = get_cc_module(endpoint_rule[0], endpoint_rule[1]) + return check_module.is_valid(self.peppol_endpoint) + + # ------------------------------------------------------------------------- + # CONSTRAINTS + # ------------------------------------------------------------------------- + + @api.constrains('account_peppol_phone_number') + def _check_account_peppol_phone_number(self): + for company in self: + if company.account_peppol_phone_number: + company._sanitize_peppol_phone_number() + + @api.constrains('peppol_endpoint') + def _check_peppol_endpoint(self): + for company in self: + if not company.peppol_endpoint: + continue + if not company._check_peppol_endpoint_number(PEPPOL_ENDPOINT_RULES): + raise ValidationError(_("The Peppol endpoint identification number is not correct.")) + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + @api.depends('is_account_peppol_participant') def _compute_account_peppol_proxy_state(self): for company in self: @@ -48,3 +158,40 @@ class ResCompany(models.Model): ) else: company.peppol_purchase_journal_id = False + + @api.depends('email') + def _compute_account_peppol_contact_email(self): + for company in self: + if not company.account_peppol_contact_email: + company.account_peppol_contact_email = company.email + + # ------------------------------------------------------------------------- + # LOW-LEVEL METHODS + # ------------------------------------------------------------------------- + + @api.model + def _sanitize_peppol_endpoint(self, vals, eas=False, endpoint=False): + if 'peppol_eas' not in vals and 'peppol_endpoint' not in vals: + return vals + + peppol_eas = vals['peppol_eas'] if 'peppol_eas' in vals else eas # let users remove the value + peppol_endpoint = vals['peppol_endpoint'] if 'peppol_endpoint' in vals else endpoint + if not peppol_eas or not peppol_endpoint: + return vals + + if peppol_eas == '0208': + cbe_match = re.search('[0-9]{10}', peppol_endpoint) + if bool(cbe_match): + vals['peppol_endpoint'] = cbe_match.group(0) + return vals + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + vals = self._sanitize_peppol_endpoint(vals) + return super().create(vals_list) + + def write(self, vals): + for company in self: + vals = self._sanitize_peppol_endpoint(vals, company.peppol_eas, company.peppol_endpoint) + return super().write(vals) diff --git a/addons/account_peppol/models/res_config_settings.py b/addons/account_peppol/models/res_config_settings.py index ec73f69980a..6e845edf2e0 100644 --- a/addons/account_peppol/models/res_config_settings.py +++ b/addons/account_peppol/models/res_config_settings.py @@ -1,25 +1,43 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from base64 import b64encode - -from odoo import api, fields, models, _ -from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError +from odoo import _, api, fields, models, modules, tools from odoo.exceptions import UserError, ValidationError +from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError +from odoo.addons.account_edi_ubl_cii.models.account_edi_common import EAS_MAPPING + +# at the moment, only European countries are accepted +ALLOWED_COUNTRIES = set(EAS_MAPPING.keys()) - {'AU', 'SG', 'NZ'} + class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' + account_peppol_edi_user = fields.Many2one( + comodel_name='account_edi_proxy_client.user', + string='EDI user', + compute='_compute_account_peppol_edi_user', + ) + account_peppol_contact_email = fields.Char(related='company_id.account_peppol_contact_email', readonly=False) account_peppol_eas = fields.Selection(related='company_id.peppol_eas', readonly=False) + account_peppol_edi_identification = fields.Char(related='account_peppol_edi_user.edi_identification') account_peppol_endpoint = fields.Char(related='company_id.peppol_endpoint', readonly=False) - account_peppol_proxy_state = fields.Selection(related='company_id.account_peppol_proxy_state') + account_peppol_endpoint_warning = fields.Char( + string="Warning", + compute="_compute_account_peppol_endpoint_warning", + ) + account_peppol_migration_key = fields.Char(related='account_peppol_edi_user.peppol_migration_key', readonly=False) + account_peppol_phone_number = fields.Char(related='company_id.account_peppol_phone_number', readonly=False) + account_peppol_proxy_state = fields.Selection(related='company_id.account_peppol_proxy_state', readonly=False) account_peppol_purchase_journal_id = fields.Many2one(related='company_id.peppol_purchase_journal_id', readonly=False) + # to be removed once the module is available account_peppol_attachment_ids = fields.Many2many( comodel_name='ir.attachment', string='Peppol Identification Documents', related='company_id.account_peppol_attachment_ids', readonly=False, ) + account_peppol_verification_code = fields.Char(related='account_peppol_edi_user.peppol_verification_code', readonly=False) is_account_peppol_eligible = fields.Boolean( string='PEPPOL eligible', compute='_compute_is_account_peppol_eligible', @@ -30,46 +48,238 @@ class ResConfigSettings(models.TransientModel): help='Register as a PEPPOL user', ) + # ------------------------------------------------------------------------- + # HELPER METHODS + # ------------------------------------------------------------------------- + + def _call_peppol_proxy(self, endpoint, params, edi_user=None): + if not edi_user: + edi_user = self.company_id.account_edi_proxy_client_ids[0] + + try: + response = edi_user._make_request( + f"{edi_user._get_server_url()}{endpoint}", + params=params, + ) + except AccountEdiProxyError as e: + raise UserError(e.message) + + if 'error' in response: + raise UserError(response['error'].get('message') or response['error']['data']['message']) + return response + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + @api.depends("company_id.country_id") def _compute_is_account_peppol_eligible(self): - # we want to show Peppol settings only to BE and LU customers at first + # we want to show Peppol settings only to customers that are eligible for Peppol, + # except countries that are not in Europe # but keeping an option to see them for testing purposes using a config param for config in self: peppol_param = config.env['ir.config_parameter'].sudo().get_param( 'account_peppol.edi.mode', False ) - config.is_account_peppol_eligible = config.company_id.country_id.code in {'BE', 'LU'} \ + config.is_account_peppol_eligible = config.company_id.country_id.code in ALLOWED_COUNTRIES \ or peppol_param == 'test' + @api.depends("company_id.account_edi_proxy_client_ids") + def _compute_account_peppol_edi_user(self): + for config in self: + config.account_peppol_edi_user = config.company_id.account_edi_proxy_client_ids.filtered( + lambda u: u.proxy_type == 'peppol') + + @api.depends('account_peppol_eas', 'account_peppol_endpoint') + def _compute_account_peppol_endpoint_warning(self): + for config in self: + if ( + not config.account_peppol_eas + or config.company_id._check_peppol_endpoint_number(warning=True) + ): + config.account_peppol_endpoint_warning = False + else: + config.account_peppol_endpoint_warning = _("The endpoint number might not be correct. " + "Please check if you entered the right identification number.") + + # ------------------------------------------------------------------------- + # BUSINESS ACTIONS + # ------------------------------------------------------------------------- + def button_create_peppol_proxy_user(self): + """ + The first step of the Peppol onboarding. + - Creates an EDI proxy user on the iap side, then the client side + - Calls /activate_participant to mark the EDI user as peppol user + """ self.ensure_one() if self.account_peppol_proxy_state != 'not_registered': raise UserError( _('Cannot register a user with a %s application', self.account_peppol_proxy_state)) - if not self.company_id.account_peppol_attachment_ids: - raise ValidationError( - _('Please upload a document that would help verifying your application')) + if not self.account_peppol_phone_number: + raise ValidationError(_("Please enter a phone number to verify your application.")) + + company = self.company_id edi_proxy_client = self.env['account_edi_proxy_client.user'] - edi_identification = edi_proxy_client._get_proxy_identification(self.company_id) + edi_identification = edi_proxy_client._get_proxy_identification(company) edi_user = edi_proxy_client.sudo()._register_proxy_user( - self.company_id, 'peppol', 'prod', edi_identification) + company, 'peppol', 'prod', edi_identification) + self.account_peppol_proxy_state = 'not_verified' - params = {'documents': []} - for attachment in self.company_id.account_peppol_attachment_ids: - params['documents'].append((attachment.name, b64encode(attachment.raw).decode())) + # if there is an error when activating the participant below, + # the client side is rolled back and the edi user is deleted on the client side + # but remains on the proxy side. + # it is important to keep these two in sync, so commit before activating. + if not tools.config['test_enable'] and not modules.module.current_test: + self.env.cr.commit() - try: - response = edi_user._make_request( - f"{edi_user._get_server_url()}/api/peppol/1/activate_participant", + company_details = { + 'peppol_company_name': company.display_name, + 'peppol_company_vat': company.vat, + 'peppol_company_street': company.street, + 'peppol_company_city': company.city, + 'peppol_company_zip': company.zip, + 'peppol_country_code': company.country_id.code, + 'peppol_phone_number': self.account_peppol_phone_number, + 'peppol_contact_email': self.account_peppol_contact_email, + } + + params = { + 'migration_key': self.account_peppol_migration_key, + 'company_details': company_details, + } + + self._call_peppol_proxy( + endpoint='/api/peppol/1/activate_participant', + params=params, + edi_user=edi_user, + ) + # once we sent the migration key over, we don't need it + # but we need the field for future in case the user decided to migrate away from Odoo + self.account_peppol_migration_key = False + + def button_update_peppol_user_data(self): + """ + Action for the user to be able to update their contact details any time + Calls /update_user on the iap server + """ + self.ensure_one() + + if not self.account_peppol_contact_email or not self.account_peppol_phone_number: + raise ValidationError(_("Contact email and phone number are required.")) + + params = { + 'update_data': { + 'peppol_phone_number': self.account_peppol_phone_number, + 'peppol_contact_email': self.account_peppol_contact_email, + } + } + + self._call_peppol_proxy( + endpoint='/api/peppol/1/update_user', + params=params, + ) + + def button_send_peppol_verification_code(self): + """ + Request user verification via SMS + Calls the /send_verification_code to send the 6-digit verification code + """ + self.ensure_one() + + # update contact details in case the user made changes + self.button_update_peppol_user_data() + + self._call_peppol_proxy( + endpoint='/api/peppol/1/send_verification_code', + params={'message': _("Your confirmation code is")}, + ) + self.account_peppol_proxy_state = 'sent_verification' + + def button_check_peppol_verification_code(self): + """ + Calls /verify_phone_number to compare user's input and the + code generated on the IAP server + """ + self.ensure_one() + + if len(self.account_peppol_verification_code) != 6: + raise ValidationError(_("The verification code should contain six digits.")) + + self._call_peppol_proxy( + endpoint='/api/peppol/1/verify_phone_number', + params={'verification_code': self.account_peppol_verification_code}, + ) + self.account_peppol_proxy_state = 'pending' + self.account_peppol_verification_code = False + + def button_cancel_peppol_registration(self): + """ + Sets the peppol registration to canceled + - If the user is active on the SMP, we can't just cancel it. + They have to request a migration key using the `button_migrate_peppol_registration` action + - 'not_registered', 'rejected', 'canceled' proxy states mean that canceling the registration + makes no sense, so we don't do it + - Calls the IAP server first before setting the state as canceled on the client side, + in case they've been activated on the IAP side in the meantime + """ + self.ensure_one() + + if self.account_peppol_proxy_state == 'active': + raise UserError(_("Can't cancel an active registration. Please request a migration instead.")) + + if self.account_peppol_proxy_state in {'not_registered', 'rejected', 'canceled'}: + raise UserError(_( + "Can't cancel registration with this status: %s", self.account_peppol_proxy_state + )) + + self._call_peppol_proxy( + endpoint='/api/peppol/1/cancel_peppol_registration', + params={}, + ) + self.account_peppol_proxy_state = 'canceled' + + def button_reopen_peppol_registration(self): + """ + Reopen a canceled or rejected peppol application. + This means that the EDI user already exists in the DB and on the IAP proxy. + So, reopening resets the user to the stage after validating (not_verified). + The user then needs to verify their phone number, even if they verified before canceling. + """ + self.ensure_one() + + if self.account_peppol_proxy_state in {'rejected', 'canceled'}: + params = { + 'update_data': { + 'peppol_state': 'draft', + } + } + + self._call_peppol_proxy( + endpoint='/api/peppol/1/update_user', params=params, ) - except AccountEdiProxyError as e: - raise UserError(e.message) - if 'error' in response: - raise UserError(response['error']) - self.company_id.account_peppol_proxy_state = 'pending' - # we don't need to store the attachments once they've been sent to the proxy - self.company_id.account_peppol_attachment_ids.unlink() + self.account_peppol_proxy_state = 'not_verified' + + def button_migrate_peppol_registration(self): + """ + If the user is active, they need to request a migration key, generated on the IAP server. + The migration key is then displayed in Peppol settings. + Currently, reopening after migrating away is not supported. + """ + self.ensure_one() + + if self.account_peppol_proxy_state != 'active': + raise UserError(_( + "Can't migrate registration with this status: %s", self.account_peppol_proxy_state + )) + + response = self._call_peppol_proxy( + endpoint='/api/peppol/1/migrate_peppol_registration', + params={}, + ) + self.account_peppol_proxy_state = 'canceled' + self.account_peppol_migration_key = response['migration_key'] diff --git a/addons/account_peppol/models/res_partner.py b/addons/account_peppol/models/res_partner.py index 560a0730c75..10a20965946 100644 --- a/addons/account_peppol/models/res_partner.py +++ b/addons/account_peppol/models/res_partner.py @@ -65,7 +65,9 @@ class ResPartner(models.Model): participant = f'{self.peppol_eas}:{self.peppol_endpoint}'.lower() hash_participant = md5(participant.lower().encode()).hexdigest() endpoint_participant = parse.quote_plus(f"iso6523-actorid-upis::{participant}") - smp_url = f"http://B-{hash_participant}.iso6523-actorid-upis.edelivery.tech.ec.europa.eu/{endpoint_participant}" + peppol_param = self.env['ir.config_parameter'].sudo().get_param('account_peppol.edi.mode', False) + sml_zone = 'acc.edelivery' if peppol_param == 'test' else 'edelivery' + smp_url = f"http://B-{hash_participant}.iso6523-actorid-upis.{sml_zone}.tech.ec.europa.eu/{endpoint_participant}" self.account_peppol_validity_last_check = fields.Date.context_today(self) try: response = requests.get(smp_url, timeout=TIMEOUT) diff --git a/addons/account_peppol/static/src/components/res_config_settings_buttons/res_config_settings_buttons.js b/addons/account_peppol/static/src/components/res_config_settings_buttons/res_config_settings_buttons.js new file mode 100644 index 00000000000..ea1417b9f3c --- /dev/null +++ b/addons/account_peppol/static/src/components/res_config_settings_buttons/res_config_settings_buttons.js @@ -0,0 +1,92 @@ +/** @odoo-module */ + +import { escape } from "@web/core/utils/strings"; +import { registry } from "@web/core/registry"; +import { pick } from "@web/core/utils/objects"; +import { useService } from "@web/core/utils/hooks"; +import { standardWidgetProps } from "@web/views/widgets/standard_widget_props"; +import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +import { Component, markup } from "@odoo/owl"; + + +class PeppolSettingsButtons extends Component { + static props = { + ...standardWidgetProps, + }; + static template = "account_peppol.ActionButtons"; + + setup() { + super.setup(); + this.dialogService = useService("dialog"); + this.notification = useService("notification"); + } + + get proxyState() { + return this.props.record.data.account_peppol_proxy_state; + } + + async _callConfigMethod(methodName, save = false) { + if (save) { + await this._save(); + } + this.env.onClickViewButton({ + clickParams: { + name: methodName, + type: "object", + noSaveDialog: true, + }, + getResParams: () => + pick(this.env.model.root, "context", "evalContext", "resModel", "resId", "resIds"), + }); + } + + async _save () { + this.env.model.root.save({ + noReload: true, + stayInEdition: true, + useSaveErrorDialog: false, + savePoint: true, + }); + } + + showConfirmation() { + const message = this.env._t("This will migrate your Peppol registration away from Odoo. You will not be able to send or receive Peppol documents in Odoo anymore."); + const confirmMessage = this.env._t("Are you sure you want to proceed?"); + this.dialogService.add(ConfirmationDialog, { + body: markup( + `
${escape(message)}
+
${escape(confirmMessage)}
` + ), + confirm: async () => { + await this._callConfigMethod("button_migrate_peppol_registration"); + }, + cancel: () => { }, + }); + } + + async updateDetails() { + // avoid making users click save on the settings + // and then clicking the update button + // changes on both the client side and the iap side need to be saved within one method + await this._callConfigMethod("button_update_peppol_user_data", true); + this.notification.add( + this.env._t("Contact details were updated."), + { type: "success" } + ); + } + + async checkCode() { + // avoid making users click save on the settings + // and then clicking the confirm button to check the code + await this._callConfigMethod("button_check_peppol_verification_code", true); + } + + async createUser() { + await this._callConfigMethod("button_create_peppol_proxy_user", true); + } +} + +registry.category("view_widgets").add("peppol_settings_buttons", { + component: PeppolSettingsButtons, +}); diff --git a/addons/account_peppol/static/src/components/res_config_settings_buttons/res_config_settings_buttons.xml b/addons/account_peppol/static/src/components/res_config_settings_buttons/res_config_settings_buttons.xml new file mode 100644 index 00000000000..54451fd2f01 --- /dev/null +++ b/addons/account_peppol/static/src/components/res_config_settings_buttons/res_config_settings_buttons.xml @@ -0,0 +1,42 @@ + + + + + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
diff --git a/addons/account_peppol/static/src/components/verification_code_widget/verification_code_widget.js b/addons/account_peppol/static/src/components/verification_code_widget/verification_code_widget.js new file mode 100644 index 00000000000..774f37cc7f3 --- /dev/null +++ b/addons/account_peppol/static/src/components/verification_code_widget/verification_code_widget.js @@ -0,0 +1,76 @@ +/** @odoo-module **/ + +import { registry } from "@web/core/registry"; +import { standardFieldProps } from "@web/views/fields/standard_field_props"; + +import { Component, onMounted, useRef } from "@odoo/owl"; + +class VerificationCodeWidget extends Component { + static props = { + ...standardFieldProps, + }; + static template = "account_peppol.VerificationCodeWidget"; + + setup() { + super.setup(); + this.inputs = []; + for (let i = 0; i < 6; i++) { + this.inputs.push(useRef(`input_${i}`)); + } + + /* + if the verification code was previously filled in and the user saved the page, + pre-fill the input fields with the stored value. + */ + onMounted(async () => { + const verificationCode = this.props.record.data.account_peppol_verification_code; + for (let i = 0; i < this.inputs.length; i++) { + this.inputs[i].el.value = verificationCode[i] || null; + } + }); + } + + /* + overrides the default paste behaviour, so that if a value is pasted + into one of the verification code fields, it's split between the input fields, + so they can easily copy and paste the code they received via SMS. + */ + onPaste(ev) { + if (!ev.clipboardData?.items) { + return; + } + ev.preventDefault(); + + let pastedData = ev.clipboardData.getData('text').split(''); + let target = ev.target; + for (let i = target.id; i < this.inputs.length; i++) { + this.inputs[i].el.value = pastedData.shift() || null; + } + } + + // switch focus to the next input box once they enter + // one digit and switch focus to the previous input box + // if they press backspace + onKeyUp(ev) { + if (ev.target.value.length === 1 && ev.target.id < 5) { + ev.target.nextElementSibling.focus(); + } else if (ev.key == 'Backspace' && ev.target.value === "" && ev.target.id > 0) { + ev.target.previousElementSibling.focus(); + } + } + + _save() { + let verificationCode = [...this.inputs.map((i) => i.el.value)].join(''); + this.props.record.update({ account_peppol_verification_code: verificationCode }); + this.env.model.root.save({ + noReload: true, + stayInEdition: true, + useSaveErrorDialog: true, + }); + } +} + +registry.category("fields").add("verification_code", { + component: VerificationCodeWidget, + supportedTypes: ["char"], +}); diff --git a/addons/account_peppol/static/src/components/verification_code_widget/verification_code_widget.xml b/addons/account_peppol/static/src/components/verification_code_widget/verification_code_widget.xml new file mode 100644 index 00000000000..656eec8f0a8 --- /dev/null +++ b/addons/account_peppol/static/src/components/verification_code_widget/verification_code_widget.xml @@ -0,0 +1,18 @@ + + + + +
+ + + +
+
+ +
diff --git a/addons/account_peppol/tests/test_peppol_messages.py b/addons/account_peppol/tests/test_peppol_messages.py index 04abe22716b..9b802a20fa6 100644 --- a/addons/account_peppol/tests/test_peppol_messages.py +++ b/addons/account_peppol/tests/test_peppol_messages.py @@ -65,7 +65,7 @@ class TestPeppolMessage(TestAccountMoveSendCommon): 'partner_id': cls.env.company.partner_id.id, }) - def create_move(self, partner, account_revenue=False, account_expense=False): + def create_move(self, partner): return self.env['account.move'].create({ 'move_type': 'out_invoice', 'partner_id': partner.id, diff --git a/addons/account_peppol/tests/test_peppol_participant.py b/addons/account_peppol/tests/test_peppol_participant.py index 6d278a72f06..5c27e685861 100644 --- a/addons/account_peppol/tests/test_peppol_participant.py +++ b/addons/account_peppol/tests/test_peppol_participant.py @@ -3,10 +3,11 @@ from contextlib import contextmanager from freezegun import freeze_time from unittest.mock import Mock, patch +from psycopg2 import IntegrityError -from odoo.exceptions import UserError, ValidationError +from odoo.exceptions import ValidationError from odoo.tests.common import tagged, TransactionCase -from odoo.tools import file_open +from odoo.tools import mute_logger ID_CLIENT = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' FAKE_UUID = 'yyyyyyyy-yyyy-yyyy-yyyy-yyyyyyyyyyyy' @@ -31,6 +32,10 @@ class TestPeppolParticipant(TransactionCase): elif url.endswith('/iap/account_edi/2/create_user'): response.json = lambda: {'result': { 'id_client': ID_CLIENT, 'refresh_token': FAKE_UUID}} + elif url.endswith('/api/peppol/1/send_verification_code')\ + or url.endswith('/api/peppol/1/update_user')\ + or url.endswith('/api/peppol/1/verify_phone_number'): + response.json = lambda: {'result': {}} else: raise Exception(f'Unexpected request: {url}') @@ -60,20 +65,19 @@ class TestPeppolParticipant(TransactionCase): 'is_account_peppol_participant': True, 'account_peppol_eas': '9925', 'account_peppol_endpoint': '0000000000', + 'account_peppol_phone_number': '+32483123456', }) company = self.env.company - with file_open(PDF_FILE_PATH, 'rb') as pdf_file: - settings.account_peppol_attachment_ids = self.env['ir.attachment'].create({ - 'mimetype': 'application/pdf', - 'name': 'peppol_id_test.pdf', - 'raw': pdf_file.read(), - }).ids - settings.execute() with self._patch_peppol_requests(): settings.button_create_peppol_proxy_user() + self.assertEqual(company.account_peppol_proxy_state, 'not_verified') + settings.button_send_peppol_verification_code() + self.assertEqual(company.account_peppol_proxy_state, 'sent_verification') + settings.account_peppol_verification_code = '123456' + settings.button_check_peppol_verification_code() self.assertEqual(company.account_peppol_proxy_state, 'pending') self.env['account_edi_proxy_client.user']._cron_peppol_get_participant_status() self.assertEqual(company.account_peppol_proxy_state, 'active') @@ -86,24 +90,19 @@ class TestPeppolParticipant(TransactionCase): 'is_account_peppol_participant': True, 'account_peppol_eas': '9925', 'account_peppol_endpoint': '0000000000', + 'account_peppol_phone_number': '+32483123456', }) company = self.env.company - with file_open(PDF_FILE_PATH, 'rb') as pdf_file: - settings.account_peppol_attachment_ids = self.env['ir.attachment'].create({ - 'mimetype': 'application/pdf', - 'name': 'peppol_id_test.pdf', - 'raw': pdf_file.read(), - }).ids - settings.execute() with self._patch_peppol_requests(reject=True): settings.button_create_peppol_proxy_user() - self.assertEqual(company.account_peppol_proxy_state, 'pending') + company.account_peppol_proxy_state = 'pending' self.env['account_edi_proxy_client.user']._cron_peppol_get_participant_status() self.assertEqual(company.account_peppol_proxy_state, 'rejected') + @mute_logger('odoo.sql_db') def test_create_duplicate_participant(self): # should not be possible to create a duplicate participant settings = self.env['res.config.settings'].create({}) @@ -111,19 +110,13 @@ class TestPeppolParticipant(TransactionCase): 'is_account_peppol_participant': True, 'account_peppol_eas': '9925', 'account_peppol_endpoint': '0000000000', + 'account_peppol_phone_number': '+32483123456', }) - with file_open(PDF_FILE_PATH, 'rb') as pdf_file: - settings.account_peppol_attachment_ids = self.env['ir.attachment'].create({ - 'mimetype': 'application/pdf', - 'name': 'peppol_id_test.pdf', - 'raw': pdf_file.read(), - }).ids - settings.execute() with self._patch_peppol_requests(): settings.button_create_peppol_proxy_user() - self.assertEqual(settings.account_peppol_proxy_state, 'pending') - with self.assertRaises(UserError), self.cr.savepoint(): + with self.assertRaises(IntegrityError), self.cr.savepoint(): + settings.account_peppol_proxy_state = 'not_registered' settings.button_create_peppol_proxy_user() diff --git a/addons/account_peppol/views/res_config_settings_views.xml b/addons/account_peppol/views/res_config_settings_views.xml index 6e228fb401b..d882f6c5e58 100644 --- a/addons/account_peppol/views/res_config_settings_views.xml +++ b/addons/account_peppol/views/res_config_settings_views.xml @@ -14,86 +14,185 @@
+ attrs="{'invisible': [('account_peppol_proxy_state', '!=', 'not_registered')]}"/>
diff --git a/addons/account_peppol/wizard/account_move_send.py b/addons/account_peppol/wizard/account_move_send.py index 76e087ce4e6..b6612abca31 100644 --- a/addons/account_peppol/wizard/account_move_send.py +++ b/addons/account_peppol/wizard/account_move_send.py @@ -146,13 +146,8 @@ class AccountMoveSend(models.Model): if not params['documents']: return - edi_user = self.env['account_edi_proxy_client.user'].search( - [ - ('company_id', '=', self.company_id.id), - ('proxy_type', '=', 'peppol'), - ], - limit=1, - ) + edi_user = self.company_id.account_edi_proxy_client_ids.filtered( + lambda u: u.proxy_type == 'peppol') try: response = edi_user._make_request( diff --git a/addons/account_peppol/wizard/account_move_send_views.xml b/addons/account_peppol/wizard/account_move_send_views.xml index a9408a93668..cf8fc822f98 100644 --- a/addons/account_peppol/wizard/account_move_send_views.xml +++ b/addons/account_peppol/wizard/account_move_send_views.xml @@ -6,9 +6,9 @@ account.move.send - + -