diff --git a/addons/account/wizard/account_move_send.py b/addons/account/wizard/account_move_send.py index 99f506f0fc7..c99a5d8be04 100644 --- a/addons/account/wizard/account_move_send.py +++ b/addons/account/wizard/account_move_send.py @@ -427,9 +427,14 @@ class AccountMoveSend(models.Model): self.ensure_one() return {} + def _call_web_service(self, prepared_data_list): + # TO OVERRIDE + return + def _generate_documents(self, moves, from_cron): """ Main entry point to generate the invoice pdf and the related attachments. """ + prepared_data_list = [] success, errors = self.env['account.move'], [] for move in moves: try: @@ -438,13 +443,23 @@ class AccountMoveSend(models.Model): if prepared_data.get('error'): raise UserError(prepared_data['error']) else: - self._render_document(move, prepared_data) - self._postprocess_document(move, prepared_data) - self._link_document(move, prepared_data) - success |= move + prepared_data_list.append((move, prepared_data)) except UserError as e: errors.append((move, str(e))) + try: + self._call_web_service(prepared_data_list) + except UserError as e: + for move, _prepared_data in prepared_data_list: + errors.append((move, str(e))) + return success, errors + + for move, prepared_data in prepared_data_list: + self._render_document(move, prepared_data) + self._postprocess_document(move, prepared_data) + self._link_document(move, prepared_data) + success |= move + return success, errors def action_send_and_print(self, from_cron=False): diff --git a/addons/account_edi_proxy_client/models/account_edi_proxy_user.py b/addons/account_edi_proxy_client/models/account_edi_proxy_user.py index 91d4b69ffcc..aa84d75f4cd 100644 --- a/addons/account_edi_proxy_client/models/account_edi_proxy_user.py +++ b/addons/account_edi_proxy_client/models/account_edi_proxy_user.py @@ -65,6 +65,7 @@ class AccountEdiProxyClientUser(models.Model): _sql_constraints = [ ('unique_id_client', 'unique(id_client)', 'This id_client is already used on another user.'), ('unique_edi_identification', 'unique(edi_identification, proxy_type, edi_mode)', 'This edi identification is already assigned to a user'), + ('unique_company_proxy', 'unique(company_id, proxy_type, edi_mode)', 'This company has a user already created for this EDI type'), ] @api.depends('company_id') @@ -189,7 +190,7 @@ class AccountEdiProxyClientUser(models.Model): if 'error' in response: raise UserError(response['error']) - self.create({ + return self.create({ 'id_client': response['id_client'], 'company_id': company.id, 'proxy_type': proxy_type, 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 5bf7d56d41e..97fdb1ee2e7 100644 --- a/addons/account_edi_ubl_cii/models/account_edi_common.py +++ b/addons/account_edi_ubl_cii/models/account_edi_common.py @@ -52,6 +52,7 @@ EAS_MAPPING = { 'CY': {'9928': 'vat'}, 'CZ': {'9929': 'vat'}, 'DE': {'9930': 'vat'}, + 'DK': {'0198': 'vat'}, 'EE': {'9931': 'vat'}, 'ES': {'9920': 'vat'}, 'FI': {'0213': 'vat'}, @@ -62,6 +63,8 @@ EAS_MAPPING = { 'HR': {'9934': 'vat'}, 'HU': {'9910': 'vat'}, 'IE': {'9935': 'vat'}, + 'IS': {'0196': 'vat'}, + 'IT': {'0210': 'l10n_it_codice_fiscale'}, 'LI': {'9936': 'vat'}, 'LT': {'9937': 'vat'}, 'LU': {'9938': 'vat'}, diff --git a/addons/account_edi_ubl_cii/models/account_move_send.py b/addons/account_edi_ubl_cii/models/account_move_send.py index 49119cecca8..c0eb78d5989 100644 --- a/addons/account_edi_ubl_cii/models/account_move_send.py +++ b/addons/account_edi_ubl_cii/models/account_move_send.py @@ -63,11 +63,14 @@ class AccountMoveSend(models.Model): # EXTENDS 'account' return super()._get_linked_attachments(move) + move.ubl_cii_xml_id + def _needs_ubl_cii_placeholder(self): + return self.enable_ubl_cii_xml and self.checkbox_ubl_cii_xml + def _get_placeholder_mail_attachments_data(self, move): # EXTENDS 'account' results = super()._get_placeholder_mail_attachments_data(move) - if self.enable_ubl_cii_xml and self.checkbox_ubl_cii_xml: + if self._needs_ubl_cii_placeholder(): builder = move.partner_id._get_edi_builder() filename = builder._export_invoice_filename(move) results.append({ diff --git a/addons/account_peppol/__init__.py b/addons/account_peppol/__init__.py new file mode 100644 index 00000000000..2ae6446f9dc --- /dev/null +++ b/addons/account_peppol/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models +from . import wizard diff --git a/addons/account_peppol/__manifest__.py b/addons/account_peppol/__manifest__.py new file mode 100644 index 00000000000..1a7972b0517 --- /dev/null +++ b/addons/account_peppol/__manifest__.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "Peppol", + 'summary': """ + This module is used to send/receive documents with PEPPOL + """, + 'description': """ +- Register as a PEPPOL participant +- Send and receive documents via PEPPOL network in Peppol BIS Billing 3.0 format + """, + 'category': 'Accounting/Accounting', + 'version': '1.0', + 'depends': [ + 'account_edi_proxy_client', + 'account_edi_ubl_cii', + ], + 'data': [ + 'data/cron.xml', + 'views/account_move_views.xml', + 'views/res_partner_views.xml', + 'views/res_config_settings_views.xml', + 'wizard/account_move_send_views.xml', + ], + 'license': 'LGPL-3', +} diff --git a/addons/account_peppol/data/cron.xml b/addons/account_peppol/data/cron.xml new file mode 100644 index 00000000000..74712ef3be9 --- /dev/null +++ b/addons/account_peppol/data/cron.xml @@ -0,0 +1,32 @@ + + + + PEPPOL: retrieve new documents + 12 + hours + -1 + + model._cron_peppol_get_new_documents() + code + + + + PEPPOL: update message status + 12 + hours + -1 + + model._cron_peppol_get_message_status() + code + + + + PEPPOL: update participant status + 1 + days + -1 + + model._cron_peppol_get_participant_status() + code + + diff --git a/addons/account_peppol/models/__init__.py b/addons/account_peppol/models/__init__.py new file mode 100644 index 00000000000..d956bf5daa1 --- /dev/null +++ b/addons/account_peppol/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_edi_proxy_user +from . import account_move +from . import res_company +from . import res_config_settings +from . import res_partner diff --git a/addons/account_peppol/models/account_edi_proxy_user.py b/addons/account_peppol/models/account_edi_proxy_user.py new file mode 100644 index 00000000000..cc11e2f2c2d --- /dev/null +++ b/addons/account_peppol/models/account_edi_proxy_user.py @@ -0,0 +1,209 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from odoo import fields, models, _ +from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +class AccountEdiProxyClientUser(models.Model): + _inherit = 'account_edi_proxy_client.user' + + proxy_type = fields.Selection( + selection_add=[('peppol', 'PEPPOL')], + ) + + def _get_proxy_urls(self): + urls = super()._get_proxy_urls() + urls['peppol'] = { + 'prod': '', + 'test': 'https://l10n-pe-edi.test.odoo.com', + } + return urls + + def _get_server_url(self, proxy_type=None, edi_mode=None): + proxy_type = proxy_type or self.proxy_type + if not proxy_type == 'peppol': + return super()._get_server_url(proxy_type, edi_mode) + + peppol_param = self.env['ir.config_parameter'].sudo().get_param( + 'account_peppol.edi.mode', False + ) + if peppol_param == 'test': + edi_mode = 'test' + + return super()._get_server_url(proxy_type, edi_mode) + + def _compute_proxy_type(self): + # Extends account_edi_proxy_client + super()._compute_proxy_type() + for user in self: + 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}' + + def _cron_peppol_get_new_documents(self): + # Retrieve all new Peppol documents for every edi user in the database + edi_users = self.env['account_edi_proxy_client.user'].search( + [('company_id.account_peppol_proxy_state', '=', 'active')]) + + for edi_user in edi_users: + proxy_acks = [] + try: + # request all messages that haven't been acknowledged + messages = edi_user._make_request( + f"{edi_user._get_server_url()}/api/peppol/1/get_all_documents") + except AccountEdiProxyError as e: + _logger.error( + 'Error while receiving the document from Peppol Proxy: %s', e.message) + continue + + message_uuids = [ + message['uuid'] + for message in messages['messages'] + if message['direction'] == 'incoming' + and message['receiver'] == edi_user.edi_identification + ] + if not message_uuids: + continue + + company = edi_user.company_id + # retrieve attachments for filtered messages + all_messages = edi_user._make_request( + f"{edi_user._get_server_url()}/api/peppol/1/get_document", + {'message_uuids': message_uuids}, + ) + + for uuid, content in all_messages.items(): + error_move_vals = { + 'move_type': 'in_invoice', + 'peppol_move_state': 'error', + 'company_id': company.id, + 'extract_can_show_send_button': False, + 'peppol_message_uuid': uuid, + } + if content.get('error'): + # in this case there is no attachment that we could add to the account move + move = self.env['account.move'].create(error_move_vals) + move._message_log(body=_('Error when receiving via Peppol: %s', content['error']['message'])) + proxy_acks.append(uuid) + continue + + enc_key = content["enc_key"] + document_content = content["document"] + filename = content["filename"] + partner_endpoint = content["accounting_supplier_party"] + decoded_document = edi_user._decrypt_data(document_content, enc_key) + + journal_id = company.peppol_purchase_journal_id + # use the first purchase journal if the Peppol journal is not set up + # to create the move anyway + if not journal_id: + journal_id = self.env['account.journal'].search( + [('company_id', '=', company.id), ('type', '=', 'purchase')], + limit=1, + ) + + attachment_vals = { + 'name': f'{filename}.xml', + 'raw': decoded_document, + 'type': 'binary', + 'mimetype': 'application/xml', + } + + try: + attachment = self.env['ir.attachment'].create(attachment_vals) + move = journal_id\ + .with_context( + default_move_type='in_invoice', + default_peppol_move_state='done', + default_extract_can_show_send_button=False, + default_peppol_message_uuid=uuid, + )\ + ._create_document_from_attachment(attachment.id) + if partner_endpoint: + move._message_log(body=_( + 'Peppol document has been received successfully. Sender endpoint: %s', partner_endpoint)) + else: + move._message_log( + body=_('Peppol document has been received successfully')) + except Exception: + # if the invoice creation fails for any reason, + # we want to create an empty invoice with the attachment + move = self.env['account.move'].create(error_move_vals) + attachment_vals.update({ + 'res_model': 'account.move', + 'res_id': move.id, + }) + self.env['ir.attachment'].create(attachment_vals) + if partner_endpoint: + move._message_log(body=_( + 'Failed to import a Peppol document. Sender endpoint: %s', partner_endpoint)) + else: + move._message_log(body=_('Failed to import a Peppol document.')) + + proxy_acks.append(uuid) + + self.env.cr.commit() + if proxy_acks: + edi_user._make_request( + f"{edi_user._get_server_url()}/api/peppol/1/ack", + {'message_uuids': proxy_acks}, + ) + + def _cron_peppol_get_message_status(self): + edi_users = self.env['account_edi_proxy_client.user'].search( + [('company_id.account_peppol_proxy_state', '=', 'active')]) + + for edi_user in edi_users: + edi_user_moves = self.env['account.move'].search([ + ('peppol_move_state', '=', 'processing'), + ('company_id', '=', edi_user.company_id.id), + ]) + if not edi_user_moves: + continue + + message_uuids = {move.peppol_message_uuid: move for move in edi_user_moves} + messages_to_process = edi_user._make_request( + f"{edi_user._get_server_url()}/api/peppol/1/get_document", + {'message_uuids': list(message_uuids.keys())}, + ) + + for uuid, content in messages_to_process.items(): + move = message_uuids[uuid] + if content.get('error'): + move.peppol_move_state = 'error' + move._message_log(body=_('Peppol error: %s', content['error']['message'])) + continue + move.peppol_move_state = content['state'] + move._message_log(body=_('Peppol update: %s', content['state'])) + + if message_uuids: + edi_user._make_request( + f"{edi_user._get_server_url()}/api/peppol/1/ack", + {'message_uuids': list(message_uuids.keys())}, + ) + + def _cron_peppol_get_participant_status(self): + edi_users = self.env['account_edi_proxy_client.user'].search( + [('company_id.account_peppol_proxy_state', '=', 'pending')]) + + for edi_user in edi_users: + try: + proxy_user = edi_user._make_request( + f"{edi_user._get_server_url()}/api/peppol/1/participant_status") + except AccountEdiProxyError as e: + _logger.error('Error while updating Peppol participant status: %s', e) + continue + + if proxy_user['peppol_state'] != 'draft': + edi_user.company_id.account_peppol_proxy_state = proxy_user['peppol_state'] diff --git a/addons/account_peppol/models/account_move.py b/addons/account_peppol/models/account_move.py new file mode 100644 index 00000000000..dcc3d02e027 --- /dev/null +++ b/addons/account_peppol/models/account_move.py @@ -0,0 +1,30 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import fields, models, _ +from odoo.exceptions import UserError + + +class AccountMove(models.Model): + _inherit = 'account.move' + + peppol_message_uuid = fields.Char(string='PEPPOL message ID') + peppol_move_state = fields.Selection( + selection=[ + ('to_send', 'To Send'), + ('processing', 'Processing'), + ('canceled', 'Canceled'), + ('done', 'Done'), + ('error', 'Error'), + ], + string='PEPPOL status', + copy=False, + readonly=True, + ) + + def action_cancel_peppol_documents(self): + # if the peppol_move_state is processing/done + # then it means it has been already sent to peppol proxy and we can't cancel + if any(move.peppol_move_state in {'processing', 'done'} for move in self): + raise UserError(_("Cannot cancel an entry that has already been sent to PEPPOL")) + self.peppol_move_state = 'canceled' diff --git a/addons/account_peppol/models/res_company.py b/addons/account_peppol/models/res_company.py new file mode 100644 index 00000000000..abca13e50b1 --- /dev/null +++ b/addons/account_peppol/models/res_company.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + account_peppol_attachment_ids = fields.Many2many( + comodel_name='ir.attachment', + string='Peppol Identification Documents', + ) + account_peppol_proxy_state = fields.Selection( + selection=[ + ('not_registered', 'Not registered'), + ('pending', 'Pending'), + ('manually_approved', 'Approved'), + ('active', 'Active'), + ('rejected', 'Rejected'), + ], + string='PEPPOL status', + compute='_compute_account_peppol_proxy_state', required=True, readonly=False, store=True, precompute=True, + ) + is_account_peppol_participant = fields.Boolean(string='PEPPOL Participant') + peppol_eas = fields.Selection(related='partner_id.peppol_eas', readonly=False) + peppol_endpoint = fields.Char(related='partner_id.peppol_endpoint', readonly=False) + peppol_purchase_journal_id = fields.Many2one( + comodel_name='account.journal', + string='PEPPOL Purchase Journal', + domain=[('type', '=', 'purchase')], + compute='_compute_peppol_purchase_journal_id', store=True, readonly=False, + ) + + @api.depends('is_account_peppol_participant') + def _compute_account_peppol_proxy_state(self): + for company in self: + if not company.account_peppol_proxy_state: + company.account_peppol_proxy_state = 'not_registered' + + @api.depends('is_account_peppol_participant') + def _compute_peppol_purchase_journal_id(self): + for company in self: + if company.is_account_peppol_participant and not company.peppol_purchase_journal_id: + company.peppol_purchase_journal_id = self.env['account.journal'].search( + [('company_id', '=', company.id), ('type', '=', 'purchase')], + limit=1, + ) + else: + company.peppol_purchase_journal_id = False diff --git a/addons/account_peppol/models/res_config_settings.py b/addons/account_peppol/models/res_config_settings.py new file mode 100644 index 00000000000..ec73f69980a --- /dev/null +++ b/addons/account_peppol/models/res_config_settings.py @@ -0,0 +1,75 @@ +# -*- 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.exceptions import UserError, ValidationError + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + account_peppol_eas = fields.Selection(related='company_id.peppol_eas', readonly=False) + 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_purchase_journal_id = fields.Many2one(related='company_id.peppol_purchase_journal_id', readonly=False) + account_peppol_attachment_ids = fields.Many2many( + comodel_name='ir.attachment', + string='Peppol Identification Documents', + related='company_id.account_peppol_attachment_ids', readonly=False, + ) + is_account_peppol_eligible = fields.Boolean( + string='PEPPOL eligible', + compute='_compute_is_account_peppol_eligible', + ) # technical field used for showing the Peppol settings conditionally + is_account_peppol_participant = fields.Boolean( + string='Use PEPPOL', + related='company_id.is_account_peppol_participant', readonly=False, + help='Register as a PEPPOL user', + ) + + @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 + # 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'} \ + or peppol_param == 'test' + + def button_create_peppol_proxy_user(self): + 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')) + edi_proxy_client = self.env['account_edi_proxy_client.user'] + edi_identification = edi_proxy_client._get_proxy_identification(self.company_id) + edi_user = edi_proxy_client.sudo()._register_proxy_user( + self.company_id, 'peppol', 'prod', edi_identification) + + params = {'documents': []} + for attachment in self.company_id.account_peppol_attachment_ids: + params['documents'].append((attachment.name, b64encode(attachment.raw).decode())) + + try: + response = edi_user._make_request( + f"{edi_user._get_server_url()}/api/peppol/1/activate_participant", + 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() diff --git a/addons/account_peppol/models/res_partner.py b/addons/account_peppol/models/res_partner.py new file mode 100644 index 00000000000..560a0730c75 --- /dev/null +++ b/addons/account_peppol/models/res_partner.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import requests +from lxml import etree +from hashlib import md5 +from urllib import parse + +from odoo import api, fields, models + +TIMEOUT = 10 + +class ResPartner(models.Model): + _inherit = 'res.partner' + + account_peppol_is_endpoint_valid = fields.Boolean( + string="PEPPOL endpoint validity", + help="The partner's EAS code and PEPPOL endpoint are valid", + compute="_compute_account_peppol_is_endpoint_valid", store=True, + copy=False, + ) + account_peppol_validity_last_check = fields.Date( + string="Checked on", + help="Last Peppol endpoint verification", + readonly=True, + copy=False, + ) + account_peppol_verification_label = fields.Selection( + selection=[ + ('not_verified', 'Not verified yet'), + ('not_valid', 'Not valid'), + ('valid', 'Valid'), + ], + string='Peppol endpoint validity', + compute='_compute_account_peppol_verification_label', + copy=False, + ) # field to compute the label to show for partner endpoint + + @api.depends('peppol_eas', 'peppol_endpoint') + def _compute_account_peppol_is_endpoint_valid(self): + # Every change in peppol_eas or peppol_endpoint should set the validity back to False + self.account_peppol_is_endpoint_valid = False + + @api.depends('account_peppol_is_endpoint_valid', 'account_peppol_validity_last_check') + def _compute_account_peppol_verification_label(self): + for partner in self: + if not partner.account_peppol_validity_last_check: + partner.account_peppol_verification_label = 'not_verified' + elif partner.account_peppol_is_endpoint_valid: + partner.account_peppol_verification_label = 'valid' + else: + partner.account_peppol_verification_label = 'not_valid' + + def button_account_peppol_check_partner_endpoint(self): + """ A basic check for whether a participant is reachable at the given + Peppol participant ID - peppol_eas:peppol_endpoint (ex: '9999:test') + The SML (Service Metadata Locator) assigns a DNS name to each peppol participant. + This DNS name resolves into the SMP (Service Metadata Publisher) of the participant. + The DNS address is of the following form: + - "http://B-" + hexstring(md5(lowercase(ID-VALUE))) + "." + ID-SCHEME + "." + SML-ZONE-NAME + "/" + url_encoded(ID-SCHEME + "::" + ID-VALUE) + (ref:https://peppol.helger.com/public/locale-en_US/menuitem-docs-doc-exchange) + """ + self.ensure_one() + + 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}" + self.account_peppol_validity_last_check = fields.Date.context_today(self) + try: + response = requests.get(smp_url, timeout=TIMEOUT) + except requests.exceptions.ConnectionError: + self.account_peppol_is_endpoint_valid = False + else: + if response.status_code != 200: + self.account_peppol_is_endpoint_valid = False + return + + participant_info = etree.XML(response.content) + participant_identifier = participant_info.find('{*}ParticipantIdentifier').text + if participant != participant_identifier: + self.account_peppol_is_endpoint_valid = False + return + + self.account_peppol_is_endpoint_valid = True diff --git a/addons/account_peppol/views/account_move_views.xml b/addons/account_peppol/views/account_move_views.xml new file mode 100644 index 00000000000..83f0a4642e3 --- /dev/null +++ b/addons/account_peppol/views/account_move_views.xml @@ -0,0 +1,77 @@ + + + + account.peppol.view.move.form + account.move + 30 + + +
+
+ + + +
+
+ + + account.move.in.invoice.tree.inherit + account.move + + + + + + + + + + account.move.credit.note.tree.inherit + account.move + + + + + + + + + + account.out.invoice.tree.inherit + account.move + + + + + + + + + + account.invoice.select.inherit + account.move + + + + + + + +
diff --git a/addons/account_peppol/views/res_config_settings_views.xml b/addons/account_peppol/views/res_config_settings_views.xml new file mode 100644 index 00000000000..6e228fb401b --- /dev/null +++ b/addons/account_peppol/views/res_config_settings_views.xml @@ -0,0 +1,105 @@ + + + + res.config.settings.view.form.inherit.proxy.user + res.config.settings + + + + +
+

PEPPOL Electronic Document Invoicing

+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ + Upload a document to verify Company information. + +
+ +
+
+
+ Application status: + + + +
+
+
+ By clicking the "Confirm" button, I accept that Odoo may process my e-invoices. +
+
+
+
+
+
+
+
+
+
diff --git a/addons/account_peppol/views/res_partner_views.xml b/addons/account_peppol/views/res_partner_views.xml new file mode 100644 index 00000000000..38408f741d0 --- /dev/null +++ b/addons/account_peppol/views/res_partner_views.xml @@ -0,0 +1,38 @@ + + + + res.partner.form.account.peppol + res.partner + 20 + + + + + + + + + + + diff --git a/addons/account_peppol/wizard/__init__.py b/addons/account_peppol/wizard/__init__.py new file mode 100644 index 00000000000..586321184b3 --- /dev/null +++ b/addons/account_peppol/wizard/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_move_send diff --git a/addons/account_peppol/wizard/account_move_send.py b/addons/account_peppol/wizard/account_move_send.py new file mode 100644 index 00000000000..906ace33e01 --- /dev/null +++ b/addons/account_peppol/wizard/account_move_send.py @@ -0,0 +1,162 @@ +# -*- 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.exceptions import UserError + + +class AccountMoveSend(models.Model): + _inherit = 'account.move.send' + + checkbox_send_peppol = fields.Boolean( + string='Send via PEPPOL', + compute='_compute_checkbox_send_peppol', store=True, readonly=False, + help='Send the invoice via PEPPOL', + ) + peppol_proxy_state = fields.Selection(related='company_id.account_peppol_proxy_state') + peppol_warning = fields.Char( + string="Warning", + compute="_compute_peppol_warning", + ) # technical field needed for computing a warning text about the peppol configuration + + # ------------------------------------------------------------------------- + # COMPUTE METHODS + # ------------------------------------------------------------------------- + + @api.depends('checkbox_ubl_cii_xml', 'enable_ubl_cii_xml') + def _compute_checkbox_send_peppol(self): + # we can't send via peppol if checkbox_ubl_cii_xml is unchecked + for wizard in self: + if not (wizard.checkbox_ubl_cii_xml and wizard.enable_ubl_cii_xml): + wizard.checkbox_send_peppol = False + + @api.depends('checkbox_send_peppol') + def _compute_mail_attachments_widget(self): + # EXTENDS 'account' - add depends + super()._compute_mail_attachments_widget() + + @api.depends('move_ids') + def _compute_peppol_warning(self): + for wizard in self: + invalid_partners = wizard.move_ids.partner_id.filtered( + lambda partner: not partner.account_peppol_is_endpoint_valid or partner.ubl_cii_format in {False, 'facturx'}) + if not invalid_partners: + wizard.peppol_warning = False + else: + names = ', '.join(invalid_partners[:5].mapped('display_name')) + wizard.peppol_warning = _("The following partners are not correctly configured to receive Peppol documents. " + "Please check and verify their Peppol endpoint and the Electronic Invoicing format: " + "%s", names) + + # ------------------------------------------------------------------------- + # ATTACHMENTS + # ------------------------------------------------------------------------- + + def _needs_ubl_cii_placeholder(self): + return super()._needs_ubl_cii_placeholder() and not self.checkbox_send_peppol + + # ------------------------------------------------------------------------- + # BUSINESS ACTIONS + # ------------------------------------------------------------------------- + + def action_send_and_print(self, from_cron=False): + # Extends the wizard to mark the move to be sent via PEPPOL first + self.ensure_one() + + if self.checkbox_send_peppol and self.peppol_proxy_state == 'active': + for move in self.move_ids: + if not move.peppol_move_state: + move.peppol_move_state = 'to_send' + + return super().action_send_and_print(from_cron) + + def _call_web_service(self, prepared_data_list): + # Overrides 'account' + if not self.checkbox_send_peppol: + return super()._call_web_service(prepared_data_list) + + params = {'documents': []} + all_moves = self.env['account.move'] + + if not prepared_data_list: + # if a user tries to resend an invoice that has a peppol error state, + # the prepared_data_list is going to be empty, as the xml has been aready generated + error_moves = self.move_ids.filtered(lambda m: m.peppol_move_state == 'error') + if not error_moves: + return + + for error_move in error_moves: + receiver_identification = f"{error_move.partner_id.peppol_eas}:{error_move.partner_id.peppol_endpoint}" + if not error_move.ubl_cii_xml_id: + # this happens when a user manually deletes the xml file + # but doesn't delete the pdf file + # they should delete both if they want to regenerate attachments + raise UserError(_('The following invoice does not have an XML attachment: %s', error_move.name)) + + params['documents'].append({ + 'filename': error_move.name, + 'receiver': receiver_identification, + 'ubl': b64encode(error_move.ubl_cii_xml_id.raw).decode(), + }) + all_moves |= error_move + + for move, prepared_data in prepared_data_list: + if not prepared_data: + move.peppol_move_state = False + raise UserError(_('Please select the UBL format')) + + if move.peppol_move_state not in ('to_send', 'error'): + continue + + xml_file = prepared_data['ubl_cii_xml_attachment_values']['raw'] + filename = prepared_data['ubl_cii_xml_attachment_values']['name'] + + if not move.partner_id.peppol_eas or not move.partner_id.peppol_endpoint: + # should never happen but in case it does, we need to handle it + move.peppol_move_state = 'error' + move._message_log(body=_('The partner is missing Peppol EAS and/or Endpoint identifier.')) + continue + + if not move.partner_id.account_peppol_is_endpoint_valid: + move.peppol_move_state = 'error' + move._message_log(body=_('Please verify partner configuration in partner settings.')) + continue + + receiver_identification = f"{move.partner_id.peppol_eas}:{move.partner_id.peppol_endpoint}" + params['documents'].append({ + 'filename': filename, + 'receiver': receiver_identification, + 'ubl': b64encode(xml_file).decode(), + }) + all_moves |= move + + edi_user = self.env['account_edi_proxy_client.user'].search([ + ('company_id', '=', self.company_id.id), + ('proxy_type', '=', 'peppol'), + ]) + + try: + response = edi_user._make_request( + f"{edi_user._get_server_url()}/api/peppol/1/send_document", + params=params, + ) + if response.get('error'): + # at the moment the only error that can happen here is ParticipantNotReady error + all_moves._message_log_batch(bodies=dict((move.id, response['error']['message']) for move in all_moves)) + all_moves.peppol_move_state = 'error' + raise UserError(response['error']['message']) + except AccountEdiProxyError as e: + all_moves._message_log_batch(bodies=dict((move.id, e.message) for move in all_moves)) + all_moves.peppol_move_state = 'error' + raise UserError(e.message) + else: + # the response only contains message uuids, + # so we have to rely on the order to connect peppol messages to account.move + for i, move in enumerate(all_moves): + move.peppol_message_uuid = response['messages'][i]['message_uuid'] + move.peppol_move_state = 'processing' + log_message = _('The document has been sent to the Peppol Access Point for processing') + all_moves._message_log_batch(bodies=dict((move.id, log_message) for move in all_moves)) diff --git a/addons/account_peppol/wizard/account_move_send_views.xml b/addons/account_peppol/wizard/account_move_send_views.xml new file mode 100644 index 00000000000..0d7ae1a3399 --- /dev/null +++ b/addons/account_peppol/wizard/account_move_send_views.xml @@ -0,0 +1,32 @@ + + + + + account.move.send.form.inherit.peppol + account.move.send + + + + + + + +
+ + + +
+
+
+
+
+