From 8eba0be6a52503293e4a05fa61522cbd3b5c21f0 Mon Sep 17 00:00:00 2001 From: "Benjamin Frantzen (bfr)" Date: Thu, 6 May 2021 14:24:53 +0000 Subject: [PATCH] [ADD] l10n_it_edi_sdicoop, account_edi_proxy_client: added support for SdiCoop webservice. - Allows to send and receives invoices from the fatturaPA network via the webservice (SdiCoop). - PEC mail is disabled when SdiCoop is disabled. - Added account_edi_proxy_client, a registered user on the proxy (see l10n_it_edi_proxy on iap-apps), that features encryption. Generates a asymmetric keys, and keep the private_key to be able to decrypt file sent by the proxy (which it encrypted with the public key). - Added a generic way to sign the requests made to the proxy. TASK ID 2358882 closes odoo/odoo#71928 X-original-commit: 4c8afc413a8982b2e77712eab7cf7ddac9f3851f Signed-off-by: Josse Colpaert Signed-off-by: bfr-o --- addons/account_edi_proxy_client/__init__.py | 1 + .../account_edi_proxy_client/__manifest__.py | 23 ++ .../i18n_extra/account_edi_proxy_client.pot | 162 +++++++++++ .../models/__init__.py | 3 + .../models/account_edi_format.py | 27 ++ .../models/account_edi_proxy_auth.py | 48 ++++ .../models/account_edi_proxy_user.py | 183 ++++++++++++ .../models/res_company.py | 9 + .../security/ir.model.access.csv | 2 + .../l10n_it_edi/data/account_invoice_demo.xml | 10 +- .../l10n_it_edi/data/invoice_it_template.xml | 4 +- .../l10n_it_edi/models/account_edi_format.py | 122 +++++++- addons/l10n_it_edi/models/account_invoice.py | 90 +----- addons/l10n_it_edi/models/ir_mail_server.py | 6 +- addons/l10n_it_edi_sdicoop/__init__.py | 14 + addons/l10n_it_edi_sdicoop/__manifest__.py | 25 ++ addons/l10n_it_edi_sdicoop/data/cron.xml | 13 + .../i18n_extra/l10n_it_edi_sdicoop.pot | 213 ++++++++++++++ addons/l10n_it_edi_sdicoop/models/__init__.py | 6 + .../models/account_edi_format.py | 267 ++++++++++++++++++ .../models/account_invoice.py | 24 ++ .../models/res_config_settings.py | 25 ++ .../views/l10n_it_view.xml | 75 +++++ .../views/res_config_settings_views.xml | 45 +++ 24 files changed, 1298 insertions(+), 99 deletions(-) create mode 100644 addons/account_edi_proxy_client/__init__.py create mode 100644 addons/account_edi_proxy_client/__manifest__.py create mode 100644 addons/account_edi_proxy_client/i18n_extra/account_edi_proxy_client.pot create mode 100644 addons/account_edi_proxy_client/models/__init__.py create mode 100644 addons/account_edi_proxy_client/models/account_edi_format.py create mode 100644 addons/account_edi_proxy_client/models/account_edi_proxy_auth.py create mode 100644 addons/account_edi_proxy_client/models/account_edi_proxy_user.py create mode 100644 addons/account_edi_proxy_client/models/res_company.py create mode 100644 addons/account_edi_proxy_client/security/ir.model.access.csv create mode 100644 addons/l10n_it_edi_sdicoop/__init__.py create mode 100644 addons/l10n_it_edi_sdicoop/__manifest__.py create mode 100644 addons/l10n_it_edi_sdicoop/data/cron.xml create mode 100644 addons/l10n_it_edi_sdicoop/i18n_extra/l10n_it_edi_sdicoop.pot create mode 100644 addons/l10n_it_edi_sdicoop/models/__init__.py create mode 100644 addons/l10n_it_edi_sdicoop/models/account_edi_format.py create mode 100644 addons/l10n_it_edi_sdicoop/models/account_invoice.py create mode 100644 addons/l10n_it_edi_sdicoop/models/res_config_settings.py create mode 100644 addons/l10n_it_edi_sdicoop/views/l10n_it_view.xml create mode 100644 addons/l10n_it_edi_sdicoop/views/res_config_settings_views.xml diff --git a/addons/account_edi_proxy_client/__init__.py b/addons/account_edi_proxy_client/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/addons/account_edi_proxy_client/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/account_edi_proxy_client/__manifest__.py b/addons/account_edi_proxy_client/__manifest__.py new file mode 100644 index 00000000000..960aac51305 --- /dev/null +++ b/addons/account_edi_proxy_client/__manifest__.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +{ + 'name': 'Proxy features for account_edi', + 'description': """ +This module adds generic features to register an Odoo DB on the proxy responsible for receiving data (via requests from web-services). +- An edi_proxy_user has a unique identification on a specific format (for example, the vat for Peppol) which +allows to identify him when receiving a document addressed to him. It is linked to a specific company on a specific +Odoo database. +- Encryption features allows to decrypt all the user's data when receiving it from the proxy. +- Authentication offers an additionnal level of security to avoid impersonification, in case someone gains to the user's database. + """, + 'version': '1.0', + 'category': 'Accounting/Accounting', + 'depends': ['account_edi'], + 'external_dependencies': { + 'python': ['cryptography'] + }, + 'data': [ + 'security/ir.model.access.csv', + ], + 'installable': True, + 'application': False, +} diff --git a/addons/account_edi_proxy_client/i18n_extra/account_edi_proxy_client.pot b/addons/account_edi_proxy_client/i18n_extra/account_edi_proxy_client.pot new file mode 100644 index 00000000000..5e3bdaa2809 --- /dev/null +++ b/addons/account_edi_proxy_client/i18n_extra/account_edi_proxy_client.pot @@ -0,0 +1,162 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * account_edi_proxy_client +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-06-03 13:44+0000\n" +"PO-Revision-Date: 2021-06-03 13:44+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: account_edi_proxy_client +#: model:ir.model,name:account_edi_proxy_client.model_account_edi_proxy_client_user +msgid "Account EDI proxy user" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_res_company__account_edi_proxy_client_ids +msgid "Account Edi Proxy Client" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__active +msgid "Active" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__edi_format_code +msgid "Code" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model,name:account_edi_proxy_client.model_res_company +msgid "Companies" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__company_id +msgid "Company" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__create_uid +msgid "Created by" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__create_date +msgid "Created on" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_format__display_name +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__display_name +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_res_company__display_name +msgid "Display Name" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model,name:account_edi_proxy_client.model_account_edi_format +msgid "EDI format" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__edi_format_id +msgid "Edi Format" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__edi_identification +msgid "Edi Identification" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_format__id +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__id +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_res_company__id +msgid "ID" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__id_client +msgid "Id Client" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_format____last_update +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user____last_update +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_res_company____last_update +msgid "Last Modified on" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__write_date +msgid "Last Updated on" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__private_key +msgid "Private Key" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,field_description:account_edi_proxy_client.field_account_edi_proxy_client_user__refresh_token +msgid "Refresh Token" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,help:account_edi_proxy_client.field_account_edi_proxy_client_user__private_key +msgid "The key to encrypt all the user's data" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.fields,help:account_edi_proxy_client.field_account_edi_proxy_client_user__edi_identification +msgid "" +"The unique id that identifies this user for on the edi format, typically the" +" vat" +msgstr "" + +#. module: account_edi_proxy_client +#: code:addons/account_edi_proxy_client/models/account_edi_proxy_user.py:0 +#, python-format +msgid "" +"The url that this service does not exist. The url it tried to contact was %s" +msgstr "" + +#. module: account_edi_proxy_client +#: code:addons/account_edi_proxy_client/models/account_edi_proxy_user.py:0 +#, python-format +msgid "" +"The url that this service requested returned an error. The url it tried to " +"contact was %s" +msgstr "" + +#. module: account_edi_proxy_client +#: code:addons/account_edi_proxy_client/models/account_edi_proxy_user.py:0 +#, python-format +msgid "" +"The url that this service requested returned an error. The url it tried to " +"contact was %s. %s" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.constraint,message:account_edi_proxy_client.constraint_account_edi_proxy_client_user_unique_edi_identification_per_format +msgid "This edi identification is already assigned to a user" +msgstr "" + +#. module: account_edi_proxy_client +#: model:ir.model.constraint,message:account_edi_proxy_client.constraint_account_edi_proxy_client_user_unique_id_client +msgid "This id_client is already used on another user." +msgstr "" diff --git a/addons/account_edi_proxy_client/models/__init__.py b/addons/account_edi_proxy_client/models/__init__.py new file mode 100644 index 00000000000..0df13f7a38e --- /dev/null +++ b/addons/account_edi_proxy_client/models/__init__.py @@ -0,0 +1,3 @@ +from . import account_edi_format +from . import account_edi_proxy_user +from . import res_company diff --git a/addons/account_edi_proxy_client/models/account_edi_format.py b/addons/account_edi_proxy_client/models/account_edi_format.py new file mode 100644 index 00000000000..0dc8bd9abe0 --- /dev/null +++ b/addons/account_edi_proxy_client/models/account_edi_format.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +from odoo import models + + +class AccountEdiFormat(models.Model): + _inherit = 'account.edi.format' + + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + def _get_proxy_user(self, company): + '''Returns the proxy_user associated with this edi format. + ''' + self.ensure_one() + return company.account_edi_proxy_client_ids.filtered(lambda u: u.edi_format_id == self) + + # ------------------------------------------------------------------------- + # To override + # ------------------------------------------------------------------------- + + def _get_proxy_identification(self, company): + '''Returns the key that will identify company uniquely for this edi format (for example, the vat) + or raises a UserError (if the user didn't fill the related field). + TO OVERRIDE + ''' + return False diff --git a/addons/account_edi_proxy_client/models/account_edi_proxy_auth.py b/addons/account_edi_proxy_client/models/account_edi_proxy_auth.py new file mode 100644 index 00000000000..b515ff670ce --- /dev/null +++ b/addons/account_edi_proxy_client/models/account_edi_proxy_auth.py @@ -0,0 +1,48 @@ +import base64 +import hashlib +import hmac +import json +import requests +import time +import werkzeug.urls + + +class OdooEdiProxyAuth(requests.auth.AuthBase): + """ For routes that needs to be authenticated and verified for access. + Allows: + 1) to preserve the integrity of the message between the endpoints. + 2) to check user access rights and account validity + 3) to avoid that multiple database use the same credentials, via a refresh_token that expire after 24h. + """ + + def __init__(self, user=False): + self.id_client = user and user.id_client or False + self.refresh_token = user and user.refresh_token or False + + def __call__(self, request): + # We don't sign request that still don't have a id_client/refresh_token + if not self.id_client or not self.refresh_token: + return request + # craft the message (timestamp|url path|id_client|query params|body content) + msg_timestamp = int(time.time()) + parsed_url = werkzeug.urls.url_parse(request.path_url) + + body = request.body + if isinstance(body, bytes): + body = body.decode() + body = json.loads(body) + + message = '%s|%s|%s|%s|%s' % ( + msg_timestamp, # timestamp + parsed_url.path, # url path + self.id_client, + json.dumps(werkzeug.urls.url_decode(parsed_url.query), sort_keys=True), # url query params sorted by key + json.dumps(body, sort_keys=True)) # http request body + h = hmac.new(base64.b64decode(self.refresh_token), message.encode(), digestmod=hashlib.sha256) + + request.headers.update({ + 'odoo-edi-client-id': self.id_client, + 'odoo-edi-signature': h.hexdigest(), + 'odoo-edi-timestamp': msg_timestamp, + }) + return request 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 new file mode 100644 index 00000000000..00e99d32102 --- /dev/null +++ b/addons/account_edi_proxy_client/models/account_edi_proxy_user.py @@ -0,0 +1,183 @@ +from odoo import models, fields, _ +from odoo.exceptions import UserError +from .account_edi_proxy_auth import OdooEdiProxyAuth + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.fernet import Fernet +import requests +import uuid +import base64 +import logging + + +_logger = logging.getLogger(__name__) + + +SERVER_URL = 'https://l10n-it-edi.api.odoo.com' +TIMEOUT = 30 + + +class AccountEdiProxyError(Exception): + + def __init__(self, code, message=False): + self.code = code + self.message = message + super().__init__(message or code) + + +class AccountEdiProxyClientUser(models.Model): + """Represents a user of the proxy for an electronic invoicing format. + An edi_proxy_user has a unique identification on a specific format (for example, the vat for Peppol) which + allows to identify him when receiving a document addressed to him. It is linked to a specific company on a specific + Odoo database. + It also owns a key with which each file should be decrypted with (the proxy encrypt all the files with the public key). + """ + _name = 'account_edi_proxy_client.user' + _description = 'Account EDI proxy user' + + active = fields.Boolean(default=True) + id_client = fields.Char(required=True, index=True) + company_id = fields.Many2one('res.company', string='Company', required=True, + default=lambda self: self.env.company) + edi_format_id = fields.Many2one('account.edi.format', required=True) + edi_format_code = fields.Char(related='edi_format_id.code', readonly=True) + edi_identification = fields.Char(required=True, help="The unique id that identifies this user for on the edi format, typically the vat") + private_key = fields.Binary(required=True, attachment=False, groups="base.group_system", help="The key to encrypt all the user's data") + refresh_token = fields.Char(groups="base.group_system") + + _sql_constraints = [ + ('unique_id_client', 'unique(id_client)', 'This id_client is already used on another user.'), + ('unique_edi_identification_per_format', 'unique(edi_identification, edi_format_id)', 'This edi identification is already assigned to a user'), + ] + + def _make_request(self, url, params=False): + ''' Make a request to proxy and handle the generic elements of the reponse (errors, new refresh token). + ''' + payload = { + 'jsonrpc': '2.0', + 'method': 'call', + 'params': params or {}, + 'id': uuid.uuid4().hex, + } + + try: + response = requests.post( + url, + json=payload, + timeout=TIMEOUT, + headers={'content-type': 'application/json'}, + auth=OdooEdiProxyAuth(user=self)).json() + except (ValueError, requests.exceptions.ConnectionError, requests.exceptions.MissingSchema, requests.exceptions.Timeout, requests.exceptions.HTTPError): + raise AccountEdiProxyError('connection_error', + _('The url that this service requested returned an error. The url it tried to contact was %s', url)) + + if 'error' in response: + message = _('The url that this service requested returned an error. The url it tried to contact was %s. %s', url, response['error']['message']) + if response['error']['code'] == 404: + message = _('The url that this service does not exist. The url it tried to contact was %s', url) + raise AccountEdiProxyError('connection_error', message) + + proxy_error = response['result'].pop('proxy_error', False) + if proxy_error: + error_code = proxy_error['code'] + if error_code == 'refresh_token_expired': + self._renew_token() + return self._make_request(url, params) + if error_code == 'no_such_user': + # This error is also raised if the user didn't exchange data and someone else claimed the edi_identificaiton. + self.active = False + raise AccountEdiProxyError(error_code, proxy_error['message'] or False) + + return response['result'] + + def _register_proxy_user(self, company, edi_format, edi_identification): + ''' Generate the public_key/private_key that will be used to encrypt the file, send a request to the proxy + to register the user with the public key and create the user with the private key. + + :param company: the company of the user. + :param edi_identification: The unique ID that identifies this user on this edi network and to which the files will be addressed. + Typically the vat. + ''' + # public_exponent=65537 is a default value that should be used most of the time, as per the documentation of cryptography. + # key_size=2048 is considered a reasonable default key size, as per the documentation of cryptography. + # see https://cryptography.io/en/latest/hazmat/primitives/asymmetric/rsa/ + private_key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + backend=default_backend() + ) + private_pem = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption() + ) + public_key = private_key.public_key() + public_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo + ) + + try: + response = self._make_request(SERVER_URL + '/iap/account_edi/1/create_user', params={ + 'dbuuid': company.env['ir.config_parameter'].get_param('database.uuid'), + 'company_id': company.id, + 'edi_format_code': edi_format.code, + 'edi_identification': edi_identification, + 'public_key': base64.b64encode(public_pem) + }) + except AccountEdiProxyError as e: + raise UserError(e.message) + if 'error' in response: + raise UserError(response['error']) + + self.create({ + 'id_client': response['id_client'], + 'company_id': company.id, + 'edi_format_id': edi_format.id, + 'edi_identification': edi_identification, + 'private_key': base64.b64encode(private_pem), + 'refresh_token': response['refresh_token'], + }) + + def _renew_token(self): + ''' Request the proxy for a new refresh token. + + Request to the proxy should be made with a refresh token that expire after 24h to avoid + that multiple database use the same credentials. When receiving an error for an expired refresh_token, + This method makes a request to get a new refresh token. + ''' + response = self._make_request(SERVER_URL + '/iap/account_edi/1/renew_token') + if 'error' in response: + # can happen if the database was duplicated and the refresh_token was refreshed by the other database. + # we don't want two database to be able to query the proxy with the same user + # because it could lead to not inconsistent data. + _logger.error(response['error']) + raise UserError('Proxy error, please contact Odoo (code: 3)') + self.refresh_token = response['refresh_token'] + + def _decrypt_data(self, data, symmetric_key): + ''' Decrypt the data. Note that the data is encrypted with a symmetric key, which is encrypted with an asymmetric key. + We must therefore decrypt the symmetric key. + + :param data: The data to decrypt. + :param symmetric_key: The symmetric_key encrypted with self.private_key.public_key() + ''' + private_key = serialization.load_pem_private_key( + base64.b64decode(self.private_key), + password=None, + backend=default_backend() + ) + key = private_key.decrypt( + base64.b64decode(symmetric_key), + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None + ) + ) + f = Fernet(key) + return f.decrypt(base64.b64decode(data)) diff --git a/addons/account_edi_proxy_client/models/res_company.py b/addons/account_edi_proxy_client/models/res_company.py new file mode 100644 index 00000000000..69e8ce4dc1a --- /dev/null +++ b/addons/account_edi_proxy_client/models/res_company.py @@ -0,0 +1,9 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = 'res.company' + + account_edi_proxy_client_ids = fields.One2many('account_edi_proxy_client.user', inverse_name='company_id') diff --git a/addons/account_edi_proxy_client/security/ir.model.access.csv b/addons/account_edi_proxy_client/security/ir.model.access.csv new file mode 100644 index 00000000000..b7551c96d47 --- /dev/null +++ b/addons/account_edi_proxy_client/security/ir.model.access.csv @@ -0,0 +1,2 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +"access_account_edi_proxy_user","access_account_edi_proxy_user","model_account_edi_proxy_client_user","base.group_system",1,1,1,0 diff --git a/addons/l10n_it_edi/data/account_invoice_demo.xml b/addons/l10n_it_edi/data/account_invoice_demo.xml index b01c3c6a00e..e298f08c4c5 100644 --- a/addons/l10n_it_edi/data/account_invoice_demo.xml +++ b/addons/l10n_it_edi/data/account_invoice_demo.xml @@ -3,14 +3,14 @@ - 0123456789987654 + 09814700101 RF01 12345 Palazzo dell'Arte - IT12345670124 + IT00000010215 Piazza Marconi 5 Cremona @@ -21,10 +21,12 @@ - IT12345670231 + IT00079760328 + XS00001 - IT12345670348 + IT00140390501 + XS00001 diff --git a/addons/l10n_it_edi/data/invoice_it_template.xml b/addons/l10n_it_edi/data/invoice_it_template.xml index 3713bd66e92..d151aee611b 100644 --- a/addons/l10n_it_edi/data/invoice_it_template.xml +++ b/addons/l10n_it_edi/data/invoice_it_template.xml @@ -52,7 +52,7 @@ - + @@ -72,7 +72,7 @@ - + diff --git a/addons/l10n_it_edi/models/account_edi_format.py b/addons/l10n_it_edi/models/account_edi_format.py index f9a740e3ec9..3dfcbc11032 100644 --- a/addons/l10n_it_edi/models/account_edi_format.py +++ b/addons/l10n_it_edi/models/account_edi_format.py @@ -20,6 +20,96 @@ DEFAULT_FACTUR_ITALIAN_DATE_FORMAT = '%Y-%m-%d' class AccountEdiFormat(models.Model): _inherit = 'account.edi.format' + # ------------------------------------------------------------------------- + # Helpers + # ------------------------------------------------------------------------- + + @api.model + def _l10n_it_edi_generate_electronic_invoice_filename(self, invoice): + '''Returns a name conform to the Fattura pa Specifications: + See ES documentation 2.2 + ''' + a = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + n = invoice.id + progressive_number = "" + while n: + (n, m) = divmod(n, len(a)) + progressive_number = a[m] + progressive_number + + return '%(country_code)s%(codice)s_%(progressive_number)s.xml' % { + 'country_code': invoice.company_id.country_id.code, + 'codice': invoice.company_id.l10n_it_codice_fiscale.replace(' ', ''), + 'progressive_number': progressive_number.zfill(5), + } + + def _l10n_it_edi_check_invoice_configuration(self, invoice): + errors = [] + seller = invoice.company_id + buyer = invoice.commercial_partner_id + + # <1.1.1.1> + if not seller.country_id: + errors.append(_("%s must have a country", seller.display_name)) + + # <1.1.1.2> + if not seller.vat: + errors.append(_("%s must have a VAT number", seller.display_name)) + elif len(seller.vat) > 30: + errors.append(_("The maximum length for VAT number is 30. %s have a VAT number too long: %s.", seller.display_name, seller.vat)) + + # <1.2.1.2> + if not seller.l10n_it_codice_fiscale: + errors.append(_("%s must have a codice fiscale number", seller.display_name)) + + # <1.2.1.8> + if not seller.l10n_it_tax_system: + errors.append(_("The seller's company must have a tax system.")) + + # <1.2.2> + if not seller.street and not seller.street2: + errors.append(_("%s must have a street.", seller.display_name)) + if not seller.zip: + errors.append(_("%s must have a post code.", seller.display_name)) + elif len(seller.zip) != 5 and seller.country_id.code == 'IT': + errors.append(_("%s must have a post code of length 5.", seller.display_name)) + if not seller.city: + errors.append(_("%s must have a city.", seller.display_name)) + if not seller.country_id: + errors.append(_("%s must have a country.", seller.display_name)) + + if seller.l10n_it_has_tax_representative and not seller.l10n_it_tax_representative_partner_id.vat: + errors.append(_("Tax representative partner %s of %s must have a tax number.", seller.l10n_it_tax_representative_partner_id.display_name, seller.display_name)) + + # <1.4.1> + if not buyer.vat and not buyer.l10n_it_codice_fiscale and buyer.country_id.code == 'IT': + errors.append(_("The buyer, %s, or his company must have either a VAT number either a tax code (Codice Fiscale).", buyer.display_name)) + + # <1.4.2> + if not buyer.street and not buyer.street2: + errors.append(_("%s must have a street.", buyer.display_name)) + if not buyer.zip: + errors.append(_("%s must have a post code.", buyer.display_name)) + if len(buyer.zip) != 5 and buyer.country_id.code == 'IT': + errors.append(_("%s must have a post code of length 5.", buyer.display_name)) + if not buyer.city: + errors.append(_("%s must have a city.", buyer.display_name)) + if not buyer.country_id: + errors.append(_("%s must have a country.", buyer.display_name)) + + # <2.2.1> + for invoice_line in invoice.invoice_line_ids: + if not invoice_line.display_type and len(invoice_line.tax_ids) != 1: + raise UserError(_("You must select one and only one tax by line.")) + + for tax_line in invoice.line_ids.filtered(lambda line: line.tax_line_id): + if not tax_line.tax_line_id.l10n_it_kind_exoneration and tax_line.tax_line_id.amount == 0: + errors.append(_("%s has an amount of 0.0, you must indicate the kind of exoneration.", tax_line.name)) + + if not invoice.partner_bank_id: + errors.append(_("The seller must have a bank account.")) + + return errors + # ------------------------------------------------------------------------- # Export # ------------------------------------------------------------------------- @@ -36,25 +126,30 @@ class AccountEdiFormat(models.Model): return super()._is_compatible_with_journal(journal) return journal.type == 'sale' and journal.country_code == 'IT' + def _l10n_it_edi_is_required_for_invoice(self, invoice): + """ Is the edi required for this invoice based on the method (here: PEC mail) + Deprecated: in future release PEC mail will be removed. + TO OVERRIDE + """ + return invoice.is_sale_document() and invoice.l10n_it_send_state not in ('sent', 'delivered', 'delivered_accepted') and invoice.country_code == 'IT' + def _is_required_for_invoice(self, invoice): # OVERRIDE self.ensure_one() if self.code != 'fattura_pa': return super()._is_required_for_invoice(invoice) - # Determine on which invoices the Mexican CFDI must be generated. - return invoice.is_sale_document() and invoice.l10n_it_send_state not in ['sent', 'delivered', 'delivered_accepted'] and invoice.country_code == 'IT' - - def _post_invoice_edi(self, invoices): - # OVERRIDE - self.ensure_one() - edi_result = super()._post_invoice_edi(invoices) - if self.code != 'fattura_pa': - return edi_result + return self._l10n_it_edi_is_required_for_invoice(invoice) + def _post_fattura_pa(self, invoices): + # TO OVERRIDE invoice = invoices # no batching ensure that we only have one invoice invoice.l10n_it_send_state = 'other' invoice._check_before_xml_exporting() + if invoice.l10n_it_einvoice_id and invoice.l10n_it_send_state not in ['invalid', 'to_send']: + return {'error': _("You can't regenerate an E-Invoice when the first one is sent and there are no errors")} + if invoice.l10n_it_einvoice_id: + invoice.l10n_it_einvoice_id.unlink() res = invoice.invoice_generate_xml() if len(invoice.commercial_partner_id.l10n_it_pa_index or '') == 6: invoice.message_post( @@ -64,6 +159,15 @@ class AccountEdiFormat(models.Model): invoice.l10n_it_send_state = 'to_send' return {invoice: res} + def _post_invoice_edi(self, invoices, test_mode=False): + # OVERRIDE + self.ensure_one() + edi_result = super()._post_invoice_edi(invoices) + if self.code != 'fattura_pa': + return edi_result + + return self._post_fattura_pa(invoices) + # ------------------------------------------------------------------------- # Import # ------------------------------------------------------------------------- diff --git a/addons/l10n_it_edi/models/account_invoice.py b/addons/l10n_it_edi/models/account_invoice.py index 8abd7fe981b..52cba731160 100644 --- a/addons/l10n_it_edi/models/account_invoice.py +++ b/addons/l10n_it_edi/models/account_invoice.py @@ -56,87 +56,14 @@ class AccountMove(models.Model): invoice.l10n_it_einvoice_name = einvoice.attachment_id.name def _check_before_xml_exporting(self): - self.ensure_one() - seller = self.company_id - buyer = self.commercial_partner_id - - # <1.1.1.1> - if not seller.country_id: - raise UserError(_("%s must have a country") % (seller.display_name)) - - # <1.1.1.2> - if not seller.vat: - raise UserError(_("%s must have a VAT number") % (seller.display_name)) - elif len(seller.vat) > 30: - raise UserError(_("The maximum length for VAT number is 30. %s have a VAT number too long: %s.") % (seller.display_name, seller.vat)) - - # <1.2.1.2> - if not seller.l10n_it_codice_fiscale: - raise UserError(_("%s must have a codice fiscale number") % (seller.display_name)) - - # <1.2.1.8> - if not seller.l10n_it_tax_system: - raise UserError(_("The seller's company must have a tax system.")) - - # <1.2.2> - if not seller.street and not seller.street2: - raise UserError(_("%s must have a street.") % (seller.display_name)) - if not seller.zip: - raise UserError(_("%s must have a post code.") % (seller.display_name)) - if len(seller.zip) != 5 and seller.country_id.code == 'IT': - raise UserError(_("%s must have a post code of length 5.") % (seller.display_name)) - if not seller.city: - raise UserError(_("%s must have a city.") % (seller.display_name)) - if not seller.country_id: - raise UserError(_("%s must have a country.") % (seller.display_name)) - - if seller.l10n_it_has_tax_representative and not seller.l10n_it_tax_representative_partner_id.vat: - raise UserError(_("Tax representative partner %s of %s must have a tax number.") % (seller.l10n_it_tax_representative_partner_id.display_name, seller.display_name)) - - # <1.4.1> - if not buyer.vat and not buyer.l10n_it_codice_fiscale and buyer.country_id.code == 'IT': - raise UserError(_("The buyer, %s, or his company must have either a VAT number either a tax code (Codice Fiscale).") % (buyer.display_name)) - - # <1.4.2> - if not buyer.street and not buyer.street2: - raise UserError(_("%s must have a street.") % (buyer.display_name)) - if not buyer.zip: - raise UserError(_("%s must have a post code.") % (buyer.display_name)) - if len(buyer.zip) != 5 and buyer.country_id.code == 'IT': - raise UserError(_("%s must have a post code of length 5.") % (buyer.display_name)) - if not buyer.city: - raise UserError(_("%s must have a city.") % (buyer.display_name)) - if not buyer.country_id: - raise UserError(_("%s must have a country.") % (buyer.display_name)) - - # <2.2.1> - for invoice_line in self.invoice_line_ids: - if not invoice_line.display_type and len(invoice_line.tax_ids) != 1: - raise UserError(_("You must select one and only one tax by line.")) - - for tax_line in self.line_ids.filtered(lambda line: line.tax_line_id): - if not tax_line.tax_line_id.l10n_it_has_exoneration and tax_line.tax_line_id.amount == 0: - raise ValidationError(_("%s has an amount of 0.0, you must indicate the kind of exoneration.", tax_line.name)) + # DEPRECATED use AccountEdiFormat._l10n_it_edi_check_invoice_configuration instead + errors = self.env['account.edi.format']._l10n_it_edi_check_invoice_configuration(self) + if errors: + raise self.env['account.edi.format']._format_error_message(_("Invalid configuration:"), errors) def invoice_generate_xml(self): self.ensure_one() - if self.l10n_it_einvoice_id and self.l10n_it_send_state not in ['invalid', 'to_send']: - return {'error': _("You can't regenerate an E-Invoice when the first one is sent and there are no errors")} - if self.l10n_it_einvoice_id: - self.l10n_it_einvoice_id.unlink() - - a = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" - n = self.id - progressive_number = "" - while n: - (n,m) = divmod(n,len(a)) - progressive_number = a[m] + progressive_number - - report_name = '%(country_code)s%(codice)s_%(progressive_number)s.xml' % { - 'country_code': self.company_id.country_id.code, - 'codice': self.company_id.l10n_it_codice_fiscale, - 'progressive_number': progressive_number.zfill(5), - } + report_name = self.env['account_edi_format']._l10n_it_edi_generate_electronic_invoice_filename(self) data = b"" + self._export_as_xml() description = _('Italian invoice: %s', self.move_type) @@ -155,9 +82,6 @@ class AccountMove(models.Model): return {'attachment': attachment} def _prepare_fatturapa_export_values(self): - ''' Create the xml file content. - :return: The XML content as str. - ''' self.ensure_one() def format_date(dt): @@ -252,6 +176,10 @@ class AccountMove(models.Model): return template_values def _export_as_xml(self): + '''DEPRECATED : this will be moved to AccountEdiFormat in a future version. + Create the xml file content. + :return: The XML content as str. + ''' template_values = self._prepare_fatturapa_export_values() content = self.env.ref('l10n_it_edi.account_invoice_it_FatturaPA_export')._render(template_values) return content diff --git a/addons/l10n_it_edi/models/ir_mail_server.py b/addons/l10n_it_edi/models/ir_mail_server.py index c9780d8f005..3b6f70c10a1 100644 --- a/addons/l10n_it_edi/models/ir_mail_server.py +++ b/addons/l10n_it_edi/models/ir_mail_server.py @@ -189,13 +189,11 @@ class FetchmailServer(models.Model): _logger.info('The xml file is badly formatted: %s', att_name) return invoices - # Create the invoice invoices = self.env.ref('l10n_it_edi.edi_fatturaPA')._create_invoice_from_xml_tree(att_name, tree) if not invoices: _logger.info('E-invoice not found in file: %s', att_name) return invoices - - invoices.l10n_it_send_state = 'new' + invoices.l10n_it_send_state = "new" invoices.invoice_source_email = from_address for invoice in invoices: invoice.with_context(no_new_invoice=True, default_res_id=invoice.id) \ @@ -409,8 +407,10 @@ class FetchmailServer(models.Model): class IrMailServer(models.Model): _name = "ir.mail_server" _inherit = "ir.mail_server" + def _get_test_email_addresses(self): self.ensure_one() + company = self.env["res.company"].search([("l10n_it_mail_pec_server_id", "=", self.id)], limit=1) if not company: # it's not a PEC server diff --git a/addons/l10n_it_edi_sdicoop/__init__.py b/addons/l10n_it_edi_sdicoop/__init__.py new file mode 100644 index 00000000000..c1eff7ed1c6 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/__init__.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import models + +from odoo import api, SUPERUSER_ID + + +def _disable_pec_mail_post_init(cr, registry): + ''' Pec mail cannot be used in conjunction with SdiCoop, so disable the Pec fetchmail servers. + ''' + env = api.Environment(cr, SUPERUSER_ID, {}) + + env['fetchmail.server'].search([('l10n_it_is_pec', '=', True)]).l10n_it_is_pec = False + env['res.company'].search([('l10n_it_mail_pec_server_id', '!=', None)]).l10n_it_mail_pec_server_id = None diff --git a/addons/l10n_it_edi_sdicoop/__manifest__.py b/addons/l10n_it_edi_sdicoop/__manifest__.py new file mode 100644 index 00000000000..c5a03e49571 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/__manifest__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': 'Italy - E-invoicing (SdiCoop)', + 'version': '0.3', + 'depends': [ + 'l10n_it_edi', + 'account_edi_extended', + 'account_edi_proxy_client', + ], + 'author': 'Odoo', + 'description': """ +E-invoice implementation for Italy with the web-service. Ability to send and receive document from SdiCoop. Files sent by SdiCoop are first stored on the proxy +and then fetched by this module. + """, + 'category': 'Accounting/Localizations/EDI', + 'website': 'http://www.odoo.com/', + 'data': [ + 'data/cron.xml', + 'views/l10n_it_view.xml', + 'views/res_config_settings_views.xml', + ], + 'post_init_hook': '_disable_pec_mail_post_init', +} diff --git a/addons/l10n_it_edi_sdicoop/data/cron.xml b/addons/l10n_it_edi_sdicoop/data/cron.xml new file mode 100644 index 00000000000..0515ecef3e1 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/data/cron.xml @@ -0,0 +1,13 @@ + + + + FatturaPA: Receive invoices from the exchange system + 1 + days + -1 + + model._cron_receive_fattura_pa() + + code + + diff --git a/addons/l10n_it_edi_sdicoop/i18n_extra/l10n_it_edi_sdicoop.pot b/addons/l10n_it_edi_sdicoop/i18n_extra/l10n_it_edi_sdicoop.pot new file mode 100644 index 00000000000..c06e62ee448 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/i18n_extra/l10n_it_edi_sdicoop.pot @@ -0,0 +1,213 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * l10n_it_edi_sdicoop +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2021-06-03 13:44+0000\n" +"PO-Revision-Date: 2021-06-03 13:44+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: l10n_it_edi_sdicoop +#: model_terms:ir.ui.view,arch_db:l10n_it_edi_sdicoop.res_config_settings_view_form +msgid "Allow Odoo to process invoices" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model_terms:ir.ui.view,arch_db:l10n_it_edi_sdicoop.res_config_settings_view_form +msgid "Already registered" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "Attached file is empty" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model_terms:ir.ui.view,arch_db:l10n_it_edi_sdicoop.res_config_settings_view_form +msgid "" +"By clicking this button, I hereby accept that Odoo may process my invoices" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model,name:l10n_it_edi_sdicoop.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_edi_format__display_name +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_move__display_name +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_res_config_settings__display_name +msgid "Display Name" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model,name:l10n_it_edi_sdicoop.model_account_edi_format +msgid "EDI format" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model_terms:ir.ui.view,arch_db:l10n_it_edi_sdicoop.res_config_settings_view_form +msgid "Electronic Document Invoicing" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "Expiration of the maximum term for communication of acceptance/refusal" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.actions.server,name:l10n_it_edi_sdicoop.ir_cron_receive_fattura_pa_invoice_ir_actions_server +#: model:ir.cron,cron_name:l10n_it_edi_sdicoop.ir_cron_receive_fattura_pa_invoice +#: model:ir.cron,name:l10n_it_edi_sdicoop.ir_cron_receive_fattura_pa_invoice +msgid "FatturaPA: Receive invoices from the exchange system" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_edi_format__id +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_move__id +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_res_config_settings__id +msgid "ID" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "" +"Invoices for PA are not managed by Odoo, you can download the document and " +"send it on your own." +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_res_config_settings__is_edi_proxy_active +msgid "Is Edi Proxy Active" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "Italian invoice: %s" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model,name:l10n_it_edi_sdicoop.model_account_move +msgid "Journal Entry" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_bank_statement_line__l10n_it_edi_attachment_id +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_move__l10n_it_edi_attachment_id +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_payment__l10n_it_edi_attachment_id +msgid "L10N It Edi Attachment" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_bank_statement_line__l10n_it_edi_transaction +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_move__l10n_it_edi_transaction +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_payment__l10n_it_edi_transaction +msgid "L10N It Edi Transaction" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_edi_format____last_update +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_account_move____last_update +#: model:ir.model.fields,field_description:l10n_it_edi_sdicoop.field_res_config_settings____last_update +msgid "Last Modified on" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "" +"Please fill your codice fiscale to be able to receive invoices from " +"FatturaPA" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: model_terms:ir.ui.view,arch_db:l10n_it_edi_sdicoop.res_config_settings_view_form +msgid "Register" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "Service momentarily unavailable" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "" +"The E-invoice is not delivered to the addressee. The Exchange System is" +" unable to deliver the file to the Public Administration." +" The Exchange System will contact the PA to report the " +"problem and request that they provide a solution. During" +" the following 15 days, the Exchange System will try to forward the " +"FatturaPA file to the Administration in question again." +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "The invoice has been refused by the Exchange System" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "" +"The invoice has been succesfully transmitted. The addressee has 15 days to " +"accept or reject it." +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "The invoice was refused by the addressee." +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "" +"The invoice was successfully transmitted to the Public Administration and we" +" are waiting for confirmation" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "" +"The invoice was successfully transmitted to the Public Administration and we" +" are waiting for confirmation." +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "Unauthorized user" +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "You are not allowed to check the status of this invoice." +msgstr "" + +#. module: l10n_it_edi_sdicoop +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#: code:addons/l10n_it_edi_sdicoop/models/account_edi_format.py:0 +#, python-format +msgid "" +"You must accept the terms and conditions in the settings to use FatturaPA." +msgstr "" diff --git a/addons/l10n_it_edi_sdicoop/models/__init__.py b/addons/l10n_it_edi_sdicoop/models/__init__.py new file mode 100644 index 00000000000..b297cf8a009 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_invoice +from . import account_edi_format +from . import res_config_settings diff --git a/addons/l10n_it_edi_sdicoop/models/account_edi_format.py b/addons/l10n_it_edi_sdicoop/models/account_edi_format.py new file mode 100644 index 00000000000..9d6600c7619 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/models/account_edi_format.py @@ -0,0 +1,267 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models, _, _lt +from odoo.exceptions import UserError +from odoo.addons.account_edi_proxy_client.models.account_edi_proxy_user import AccountEdiProxyError, SERVER_URL + +from lxml import etree +import base64 +import logging + +_logger = logging.getLogger(__name__) + + +class AccountEdiFormat(models.Model): + _inherit = 'account.edi.format' + + # ------------------------------------------------------------------------- + # Import + # ------------------------------------------------------------------------- + + def _cron_receive_fattura_pa(self): + ''' Check the proxy for incoming invoices. + ''' + proxy_users = self.env['account_edi_proxy_client.user'].search([('edi_format_id', '=', self.env.ref('l10n_it_edi.edi_fatturaPA').id)]) + for proxy_user in proxy_users: + company = proxy_user.company_id + try: + res = proxy_user._make_request(SERVER_URL + '/api/l10n_it_edi/1/in/RicezioneInvoice', + params={'recipient_codice_fiscale': company.l10n_it_codice_fiscale}) + except AccountEdiProxyError as e: + _logger.error('Error while receiving file from SdiCoop: %s', e) + + proxy_acks = [] + for id_transaction, fattura in res.items(): + if self.env['ir.attachment'].search([('name', '=', fattura['filename']), ('res_model', '=', 'account.move')], limit=1): + # name should be unique, the invoice already exists + _logger.info('E-invoice already exist: %s', fattura['filename']) + proxy_acks.append(id_transaction) + continue + + file = proxy_user._decrypt_data(fattura['file'], fattura['key']) + + try: + tree = etree.fromstring(file) + except Exception: + # should not happen as the file has been checked by SdiCoop + _logger.info('Received file badly formatted, skipping: \n %s', file) + continue + + invoice = self.env.ref('l10n_it_edi.edi_fatturaPA')._create_invoice_from_xml_tree(fattura['filename'], tree) + self.env['ir.attachment'].create({ + 'name': fattura['filename'], + 'raw': file, + 'type': 'binary', + 'res_model': 'account.move', + 'res_id': invoice.id + }) + + proxy_acks.append(id_transaction) + try: + proxy_user._make_request(SERVER_URL + '/api/l10n_it_edi/1/ack', + params={'transaction_ids': proxy_acks}) + except AccountEdiProxyError as e: + _logger.error('Error while receiving file from SdiCoop: %s', e) + + # ------------------------------------------------------------------------- + # Export + # ------------------------------------------------------------------------- + + def _check_move_configuration(self, move): + # OVERRIDE + res = super()._check_move_configuration(move) + if self.code != 'fattura_pa': + return res + + res.extend(self._l10n_it_edi_check_invoice_configuration(move)) + + if not self._get_proxy_user(move.company_id): + res.append(_("You must accept the terms and conditions in the settings to use FatturaPA.")) + + return res + + def _needs_web_services(self): + self.ensure_one() + return self.code == 'fattura_pa' or super()._needs_web_services() + + def _l10n_it_edi_is_required_for_invoice(self, invoice): + """ _is_required_for_invoice for SdiCoop. + OVERRIDE + """ + return invoice.is_sale_document() and invoice.country_code == 'IT' + + def _support_batching(self, move=None, state=None, company=None): + # OVERRIDE + if self.code == 'fattura_pa': + return state == 'to_send' and move.is_invoice() + + return super()._support_batching(move=move, state=state, company=company) + + def _l10n_it_post_invoices_step_1(self, invoices): + ''' Send the invoices to the proxy. + ''' + to_return = {} + + to_send = {} + for invoice in invoices: + xml = b"" + invoice._export_as_xml() + filename = self._l10n_it_edi_generate_electronic_invoice_filename(invoice) + attachment = self.env['ir.attachment'].create({ + 'name': filename, + 'res_id': invoice.id, + 'res_model': invoice._name, + 'datas': base64.encodebytes(xml), + 'description': _('Italian invoice: %s', invoice.move_type), + 'type': 'binary', + }) + invoice.l10n_it_edi_attachment_id = attachment + + if len(invoice.commercial_partner_id.l10n_it_pa_index or '') == 6: + invoice.message_post( + body=(_("Invoices for PA are not managed by Odoo, you can download the document and send it on your own.")) + ) + to_return[invoice] = {'attachment': attachment} + else: + to_send[filename] = { + 'invoice': invoice, + 'data': {'filename': filename, 'xml': base64.b64encode(xml)}} + + company = invoices.company_id + proxy_user = self._get_proxy_user(company) + if not proxy_user: # proxy user should exist, because there is a check in _check_move_configuration + return {invoice: { + 'error': _("You must accept the terms and conditions in the settings to use FatturaPA."), + 'blocking_level': 'error'} for invoice in invoices} + try: + responses = self._l10n_it_edi_upload([i['data'] for i in to_send.values()], proxy_user) + except AccountEdiProxyError as e: + return {invoice: {'error': e.message, 'blocking_level': 'error'} for invoice in invoices} + + for filename, response in responses.items(): + invoice = to_send[filename]['invoice'] + to_return[invoice] = response + if 'id_transaction' in response: + invoice.l10n_it_edi_transaction = response['id_transaction'] + to_return[invoice].update({ + 'error': _('The invoice was successfully transmitted to the Public Administration and we are waiting for confirmation.'), + 'blocking_level': 'info', + }) + return to_return + + def _l10n_it_post_invoices_step_2(self, invoices): + ''' Check if the sent invoices have been processed by FatturaPA. + ''' + to_check = {i.l10n_it_edi_transaction: i for i in invoices} + to_return = {} + company = invoices.company_id + proxy_user = self._get_proxy_user(company) + if not proxy_user: # proxy user should exist, because there is a check in _check_move_configuration + return {invoice: { + 'error': _("You must accept the terms and conditions in the settings to use FatturaPA."), + 'blocking_level': 'error'} for invoice in invoices} + try: + responses = proxy_user._make_request(SERVER_URL + '/api/l10n_it_edi/1/in/TrasmissioneFatture', + params={'ids_transaction': list(to_check.keys())}) + except AccountEdiProxyError as e: + return {invoice: {'error': e.message, 'blocking_level': 'error'} for invoice in invoices} + + proxy_acks = [] + for id_transaction, response in responses.items(): + invoice = to_check[id_transaction] + if 'error' in response: + to_return[invoice] = response + continue + + state = response['state'] + if state == 'awaiting_outcome': + to_return[invoice] = { + 'error': _('The invoice was successfully transmitted to the Public Administration and we are waiting for confirmation'), + 'blocking_level': 'info', + } + proxy_acks.append(id_transaction) + continue + elif state == 'not_found': + # Invoice does not exist on proxy. Either it does not belong to this proxy_user or it was not created correctly when + # it was sent to the proxy. + to_return[invoice] = {'error': _('You are not allowed to check the status of this invoice.'), 'blocking_level': 'error'} + continue + + xml = proxy_user._decrypt_data(response['file'], response['key']) + response_tree = etree.fromstring(xml) + if state == 'ricevutaConsegna': + to_return[invoice] = {'error': _('The invoice has been succesfully transmitted. The addressee has 15 days to accept or reject it.')} + elif state == 'notificaScarto': + errors = [element.find('Descrizione').text for element in response_tree.xpath('//Errore')] + to_return[invoice] = {'error': self._format_error_message(_('The invoice has been refused by the Exchange System'), errors), 'blocking_level': 'error'} + elif state == 'notificaMancataConsegna': + to_return[invoice] = { + 'error': _('The E-invoice is not delivered to the addressee. The Exchange System is\ + unable to deliver the file to the Public Administration. The Exchange System will\ + contact the PA to report the problem and request that they provide a solution. \ + During the following 15 days, the Exchange System will try to forward the FatturaPA\ + file to the Administration in question again.'), + } + elif state == 'notificaEsito': + outcome = response_tree.find('Esito').text + if outcome == 'EC01': + to_return[invoice] = {'attachment': invoice.l10n_it_edi_attachment_id} + else: # ECO2 + to_return[invoice] = {'error': _('The invoice was refused by the addressee.'), 'blocking_level': 'error'} + elif state == 'NotificaDecorrenzaTermini': + to_return[invoice] = {'error': _('Expiration of the maximum term for communication of acceptance/refusal'), 'blocking_level': 'error'} + proxy_acks.append(id_transaction) + + try: + proxy_user._make_request(SERVER_URL + '/api/l10n_it_edi/1/ack', + params={'transaction_ids': proxy_acks}) + except AccountEdiProxyError as e: + # Will be ignored and acked again next time. + _logger.error('Error while acking file to SdiCoop: %s', e) + + return to_return + + def _post_fattura_pa(self, invoices): + # OVERRIDE + if not invoices.l10n_it_edi_transaction: + return self._l10n_it_post_invoices_step_1(invoices) + else: + return self._l10n_it_post_invoices_step_2(invoices) + + # ------------------------------------------------------------------------- + # Proxy methods + # ------------------------------------------------------------------------- + + def _get_proxy_identification(self, company): + if self.code != 'fattura_pa': + return super()._get_proxy_identification() + + if not company.l10n_it_codice_fiscale: + raise UserError(_('Please fill your codice fiscale to be able to receive invoices from FatturaPA')) + + return company.l10n_it_codice_fiscale + + def _l10n_it_edi_upload(self, files, proxy_user): + '''Upload files to fatturapa. + + :param files: A list of dictionary {filename, base64_xml}. + :returns: A dictionary. + * message: Message from fatturapa. + * transactionId: The fatturapa ID of this request. + * error: An eventual error. + * error_level: Info, warning, error. + ''' + ERRORS = { + 'EI01': {'error': _lt('Attached file is empty'), 'blocking_level': 'error'}, + 'EI02': {'error': _lt('Service momentarily unavailable'), 'blocking_level': 'warning'}, + 'EI03': {'error': _lt('Unauthorized user'), 'blocking_level': 'error'}, + } + + result = proxy_user._make_request(SERVER_URL + '/api/l10n_it_edi/1/out/SdiRiceviFile', params={'files': files}) + + # Translate the errors. + for filename in result.keys(): + if 'error' in result[filename]: + result[filename] = ERRORS.get(result[filename]['error'], {'error': result[filename]['error'], 'blocking_level': 'error'}) + + return result diff --git a/addons/l10n_it_edi_sdicoop/models/account_invoice.py b/addons/l10n_it_edi_sdicoop/models/account_invoice.py new file mode 100644 index 00000000000..301b07f3c03 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/models/account_invoice.py @@ -0,0 +1,24 @@ +# -*- coding:utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import logging + +from odoo import fields, models + + +_logger = logging.getLogger(__name__) + +DEFAULT_FACTUR_ITALIAN_DATE_FORMAT = '%Y-%m-%d' + + +class AccountMove(models.Model): + _inherit = 'account.move' + + l10n_it_edi_transaction = fields.Char() + l10n_it_edi_attachment_id = fields.Many2one('ir.attachment') + + def send_pec_mail(self): + self.ensure_one() + # OVERRIDE + # With SdiCoop web-service, no need to send PEC mail. + # Set the state to 'other' because the invoice should not be managed par l10n_it_edi. + self.l10n_it_send_state = 'other' diff --git a/addons/l10n_it_edi_sdicoop/models/res_config_settings.py b/addons/l10n_it_edi_sdicoop/models/res_config_settings.py new file mode 100644 index 00000000000..04d7874315d --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/models/res_config_settings.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- + +from odoo import api, models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + is_edi_proxy_active = fields.Boolean(compute='_compute_is_edi_proxy_active') + + @api.depends('company_id.account_edi_proxy_client_ids', 'company_id.account_edi_proxy_client_ids.active') + def _compute_is_edi_proxy_active(self): + for config in self: + config.is_edi_proxy_active = config.company_id.account_edi_proxy_client_ids + + def button_create_proxy_user(self): + # For now, only fattura_pa uses the proxy. + # To use it for more, we have to either make the activation of the proxy on a format basis + # or create a user per format here (but also when installing new formats) + fattura_pa = self.env.ref('l10n_it_edi.edi_fatturaPA') + edi_identification = fattura_pa._get_proxy_identification(self.company_id) + if not edi_identification: + return + + self.env['account_edi_proxy_client.user']._register_proxy_user(self.company_id, fattura_pa, edi_identification) diff --git a/addons/l10n_it_edi_sdicoop/views/l10n_it_view.xml b/addons/l10n_it_edi_sdicoop/views/l10n_it_view.xml new file mode 100644 index 00000000000..40ea1041064 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/views/l10n_it_view.xml @@ -0,0 +1,75 @@ + + + + fetchmail.server.form.l10n.it.inherit + fetchmail.server + 30 + + + + + + + + + + res.company.form.l10n.it.inherit + res.company + 30 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/l10n_it_edi_sdicoop/views/res_config_settings_views.xml b/addons/l10n_it_edi_sdicoop/views/res_config_settings_views.xml new file mode 100644 index 00000000000..3929c555ae3 --- /dev/null +++ b/addons/l10n_it_edi_sdicoop/views/res_config_settings_views.xml @@ -0,0 +1,45 @@ + + + + + res.config.settings.view.form.inherit.proxy.user + res.config.settings + + + +
+

Electronic Document Invoicing

+
+
+
+ Allow Odoo to process invoices +
+ By clicking this button, I accept that Odoo may process my invoices. +
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+ +