[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 <jco@openerp.com>
Signed-off-by: bfr-o <bfr-o@users.noreply.github.com>
This commit is contained in:
Benjamin Frantzen (bfr)
2021-06-09 18:22:33 +00:00
parent 0897b49114
commit 8eba0be6a5
24 changed files with 1298 additions and 99 deletions
@@ -0,0 +1 @@
from . import models
@@ -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,
}
@@ -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 ""
@@ -0,0 +1,3 @@
from . import account_edi_format
from . import account_edi_proxy_user
from . import res_company
@@ -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
@@ -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
@@ -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))
@@ -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')
@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_account_edi_proxy_user access_account_edi_proxy_user model_account_edi_proxy_client_user base.group_system 1 1 1 0
@@ -3,14 +3,14 @@
<data noupdate="1">
<!-- add VAT, codice fiscal and tax system for main company -->
<record id="l10n_it.demo_company_it" model="res.company">
<field name="l10n_it_codice_fiscale">0123456789987654</field>
<field name="l10n_it_codice_fiscale">09814700101</field>
<field name="l10n_it_tax_system">RF01</field>
<field name="zip">12345</field>
</record>
<record id="partner_demo_it" model="res.partner">
<field name="name">Palazzo dell'Arte</field>
<field name="vat">IT12345670124</field>
<field name="vat">IT00000010215</field>
<field name="street">Piazza Marconi 5</field>
<field name="city">Cremona</field>
<field name="country_id" ref="base.it"/>
@@ -21,10 +21,12 @@
</record>
<record id="base.res_partner_2" model="res.partner">
<field name="vat">IT12345670231</field>
<field name="vat">IT00079760328</field>
<field name="l10n_it_pa_index">XS00001</field>
</record>
<record id="base.res_partner_12" model="res.partner">
<field name="vat">IT12345670348</field>
<field name="vat">IT00140390501</field>
<field name="l10n_it_pa_index">XS00001</field>
</record>
</data>
@@ -52,7 +52,7 @@
<DatiTrasmissione>
<IdTrasmittente>
<IdPaese t-esc="get_vat_country(record.company_id.vat)"/>
<IdCodice t-esc="record.company_id.l10n_it_codice_fiscale"/>
<IdCodice t-esc="get_vat_number(record.company_id.vat)"/>
</IdTrasmittente>
<ProgressivoInvio t-esc="record.name.replace('/','')[-10:]"/>
<FormatoTrasmissione t-esc="formato_trasmissione"/>
@@ -72,7 +72,7 @@
<IdPaese t-esc="get_vat_country(record.company_id.vat)"/>
<IdCodice t-esc="get_vat_number(record.company_id.vat)"/>
</IdFiscaleIVA>
<CodiceFiscale t-if="record.company_id.l10n_it_codice_fiscale" t-esc="record.company_id.l10n_it_codice_fiscale"/>
<CodiceFiscale t-if="record.company_id.l10n_it_codice_fiscale and not test_mode" t-esc="record.company_id.l10n_it_codice_fiscale"/>
<Anagrafica>
<Denominazione t-esc="record.company_id.partner_id.display_name"/>
</Anagrafica>
+113 -9
View File
@@ -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
# -------------------------------------------------------------------------
+9 -81
View File
@@ -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"<?xml version='1.0' encoding='UTF-8'?>" + 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
+3 -3
View File
@@ -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
+14
View File
@@ -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
@@ -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',
}
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="ir_cron_receive_fattura_pa_invoice" model="ir.cron">
<field name="name">FatturaPA: Receive invoices from the exchange system</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="numbercall">-1</field>
<field name="model_id" ref="account_edi.model_account_edi_format"/>
<field name="code">model._cron_receive_fattura_pa()</field>
<field name="doall" eval="False"/>
<field name="state">code</field>
</record>
</odoo>
@@ -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 "<span class=\"o_form_label\">Allow Odoo to process invoices</span>"
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 ""
@@ -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
@@ -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"<?xml version='1.0' encoding='UTF-8'?>" + 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
@@ -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'
@@ -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)
@@ -0,0 +1,75 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fetchmail_server_form_l10n_it_inherit" model="ir.ui.view">
<field name="name">fetchmail.server.form.l10n.it.inherit</field>
<field name="model">fetchmail.server</field>
<field name="priority">30</field>
<field name="inherit_id" ref="l10n_it_edi.fetchmail_server_form_l10n_it"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='l10n_it_is_pec']" position="replace" />
</data>
</field>
</record>
<record id="res_company_form_l10n_it_inherit" model="ir.ui.view">
<field name="name">res.company.form.l10n.it.inherit</field>
<field name="model">res.company</field>
<field name="priority">30</field>
<field name="inherit_id" ref="l10n_it_edi.res_company_form_l10n_it"/>
<field name="arch" type="xml">
<data>
<xpath expr="//field[@name='l10n_it_mail_pec_server_id']" position="replace"/>
<xpath expr="//field[@name='l10n_it_address_send_fatturapa']" position="replace"/>
<xpath expr="//field[@name='l10n_it_address_recipient_fatturapa']" position="replace"/>
</data>
</field>
</record>
<record id="l10n_it_edi.invoice_supplier_tree_l10n_it" model="ir.ui.view">
<field name="arch" type="xml">
<!-- Remove the l10n_state_it field. Doing so
with xpath might replace other view elements that we shouldn't remove.-->
<data/>
</field>
</record>
<record id="l10n_it_edi.invoice_kanban_l10n_it" model="ir.ui.view">
<field name="arch" type="xml">
<!-- Remove the l10n_state_it field. Doing so
with xpath might replace other view elements that we shouldn't remove.-->
<data/>
</field>
</record>
<record id="l10n_it_edi.account_invoice_form_l10n_it" model="ir.ui.view">
<field name="arch" type="xml">
<!-- Remove the l10n_state_it field but keep other info tab.
Doing so with xpath might repace other view elements that we shouldn't remove. -->
<data>
<xpath expr="//page[@name='other_info']" position="after">
<page string="Electronic Invoicing"
name="electronic_invoicing"
attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'out_refund', 'in_invoice', 'in_refund'))]}">
<group>
<group>
<field name="l10n_it_stamp_duty"/>
<field name="l10n_it_ddt_id"
attrs="{'invisible': [('move_type', 'not in', ('out_invoice', 'out_refund'))]}"/>
</group>
</group>
</page>
</xpath>
</data>
</field>
</record>
<record id="l10n_it_edi.view_account_invoice_filter_l10n_it" model="ir.ui.view">
<field name="arch" type="xml">
<!-- Remove the filters.-->
<data />
</field>
</record>
</odoo>
@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.proxy.user</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="account.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//div[@id='account_vendor_bills']" position="after">
<div attrs="{'invisible':[('country_code', '!=', 'IT')]}">
<h2>Electronic Document Invoicing</h2>
<div class="row mt16 o_settings_container" id='account_edi'>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Allow Odoo to process invoices</span>
<div class="text-muted">
By clicking this button, I accept that Odoo may process my invoices.
</div>
<div class="content-group">
<field name="is_edi_proxy_active" invisible="True" />
<div class="row mt8">
<div class="col-lg-6" title="Register">
<button name="button_create_proxy_user"
type="object"
string="Register"
class="btn-primary" icon="fa-lg fa-check"
attrs="{'invisible':[('is_edi_proxy_active', '=', True)]}"/>
<button name="button_create_proxy_user"
type="object"
string="Already registered"
disabled="1"
class="btn-lnk" icon="fa-lg fa-check"
attrs="{'invisible':[('is_edi_proxy_active', '=', False)]}"/>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>