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
+ 09814700101RF0112345Palazzo dell'Arte
- IT12345670124
+ IT00000010215Piazza Marconi 5Cremona
@@ -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.
+