482 lines
21 KiB
Python
482 lines
21 KiB
Python
import base64
|
|
import hashlib
|
|
import io
|
|
import logging
|
|
import re
|
|
import requests
|
|
import secrets
|
|
import string
|
|
import uuid
|
|
import zipfile
|
|
|
|
from cryptography import x509
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives import hashes, serialization
|
|
from cryptography.hazmat.primitives.serialization import Encoding
|
|
from datetime import datetime, timedelta, timezone
|
|
from markupsafe import Markup, escape
|
|
from json.decoder import JSONDecodeError
|
|
from urllib.parse import urlencode
|
|
|
|
from odoo import api, fields, models, modules, tools
|
|
from odoo.exceptions import AccessError, RedirectWarning, UserError
|
|
from odoo.http import request
|
|
from odoo.tools import LazyTranslate
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import jwt
|
|
from jwt.exceptions import ImmatureSignatureError, InvalidSignatureError
|
|
except ImportError:
|
|
jwt = None
|
|
_logger.error("The PyJWT module is not installed, Intervat might not work as expected.")
|
|
|
|
ALLOWED_CHARS = string.ascii_letters + string.digits + "-._~"
|
|
BASE_URL = {
|
|
'test': "https://wsapi-a.minfin.be",
|
|
'prod': "https://wsapi.minfin.fgov.be",
|
|
'disabled': "",
|
|
}
|
|
TOKEN_ENDPOINT = {
|
|
'test': "https://fediamapi-a.minfin.be/sso/oauth2/access_token",
|
|
'prod': "https://fediamapi.minfin.fgov.be/sso/oauth2/access_token",
|
|
'disabled': "",
|
|
}
|
|
JWKS_ENDPOINT = {
|
|
'test': "https://fediamapi-a.minfin.be/sso/oauth2/connect/jwk_uri",
|
|
'prod': "https://fediamapi.minfin.fgov.be/sso/oauth2/connect/jwk_uri",
|
|
'disabled': "",
|
|
}
|
|
IAP_ENDPOINT = {
|
|
'test': "https://l10n-be-intervat.test.odoo.com/api/l10n_be_intervat/1",
|
|
'prod': "https://l10n-be-intervat.api.odoo.com/api/l10n_be_intervat/1",
|
|
'disabled': "",
|
|
}
|
|
|
|
_intervat_logger = logging.getLogger(__name__ + '.l10n_be_intervat')
|
|
_lt = LazyTranslate(__name__)
|
|
|
|
IAP_ERROR_MESSAGE = {
|
|
'error_subscription': _lt("An error has occurred when trying to verify your subscription."),
|
|
'dbuuid_not_exist': _lt("Your database UUID does not exist."),
|
|
'not_enterprise': _lt("You do not have an Odoo Enterprise subscription."),
|
|
'not_prod_env': _lt("Your database is not used for a production environment."),
|
|
'not_active_db': _lt("Your database is not yet activated."),
|
|
'limit_call_reached': _lt("You reached the call limit. Please try again in a moment."),
|
|
}
|
|
L10N_BE_INTERVAT_TOKEN_TIMEOUT_LIMIT = 10
|
|
|
|
|
|
class ResCompany(models.Model):
|
|
_inherit = 'res.company'
|
|
|
|
l10n_be_intervat_refresh_token = fields.Char(groups='account.group_account_user')
|
|
l10n_be_intervat_access_token = fields.Char(groups='account.group_account_user')
|
|
l10n_be_intervat_code_challenge = fields.Char(groups='account.group_account_user')
|
|
l10n_be_intervat_code_verifier = fields.Char(groups='account.group_account_user')
|
|
l10n_be_intervat_client_id = fields.Char(string="Intervat Client ID", groups='account.group_account_user')
|
|
l10n_be_intervat_private_key = fields.Many2one(
|
|
string="Intervat Private Key",
|
|
comodel_name='certificate.key',
|
|
domain=[('public', '=', False)],
|
|
groups='account.group_account_user',
|
|
)
|
|
l10n_be_intervat_certificate_id = fields.Many2one(
|
|
string="Intervat JWT Certificate",
|
|
comodel_name='certificate.certificate',
|
|
domain=[('is_valid', '=', True)],
|
|
groups='account.group_account_user',
|
|
)
|
|
l10n_be_intervat_mode = fields.Selection(
|
|
selection=[
|
|
('prod', "Production"),
|
|
('test', "Test"),
|
|
('disabled', "Disabled"),
|
|
],
|
|
default="prod",
|
|
string="Intervat Server Mode",
|
|
help="""
|
|
- Production: Connected to the Intervat API in production mode
|
|
- Test: Connected to the Intervat API in test mode
|
|
- Disabled: Disconnected from the Intervat server
|
|
""",
|
|
groups='account.group_account_user',
|
|
required=True,
|
|
)
|
|
l10n_be_intervat_last_call_date = fields.Datetime(groups='account.group_account_user')
|
|
l10n_be_intervat_show_settings = fields.Boolean(compute="_compute_l10n_be_intervat_show_settings")
|
|
|
|
@api.depends('account_enabled_tax_country_ids')
|
|
def _compute_l10n_be_intervat_show_settings(self):
|
|
for company in self:
|
|
company.l10n_be_intervat_show_settings = 'BE' in company.account_enabled_tax_country_ids.mapped('code')
|
|
|
|
########################################
|
|
# Tokens & Certificates #
|
|
########################################
|
|
|
|
def _l10n_be_generate_private_key_and_certificate(self):
|
|
""" Generate a new certificate and a new private key to be used for api calls.
|
|
The certificate follows the x509 standard.
|
|
This certificate is supposed to be stored in the jwks url, to be retrieved by the government apis
|
|
to check the jwt signature.
|
|
"""
|
|
if not self.env.user.has_group("account.group_account_user"):
|
|
raise AccessError(self.env._("Only an accountant can perform this action."))
|
|
|
|
# We only generate a new certificate and a new key if nothing is set on the company yet.
|
|
# Otherwise, we're using the same.
|
|
if not self.l10n_be_intervat_private_key or not self.l10n_be_intervat_certificate_id.sudo().is_valid:
|
|
if self.l10n_be_intervat_certificate_id:
|
|
self.l10n_be_intervat_certificate_id.sudo().unlink()
|
|
self.l10n_be_intervat_private_key.sudo().unlink()
|
|
self.sudo().l10n_be_intervat_private_key = self.env['certificate.key'].sudo()._generate_rsa_private_key(
|
|
company=self,
|
|
name='intervat_private_key_%s' % fields.Datetime.now().strftime("%Y%m%d_%H%M%S"),
|
|
)
|
|
subject = x509.Name([
|
|
x509.NameAttribute(x509.oid.NameOID.COUNTRY_NAME, self.country_code),
|
|
x509.NameAttribute(x509.oid.NameOID.ORGANIZATION_NAME, self.name),
|
|
x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, self.website or 'N/A'),
|
|
x509.NameAttribute(x509.oid.NameOID.LOCALITY_NAME, self.city or 'N/A'),
|
|
])
|
|
private_key = serialization.load_pem_private_key(
|
|
base64.b64decode(self.l10n_be_intervat_private_key.pem_key),
|
|
None
|
|
)
|
|
certificate = x509.CertificateBuilder().subject_name(
|
|
subject
|
|
).issuer_name(
|
|
subject
|
|
).public_key(
|
|
private_key.public_key()
|
|
).serial_number(
|
|
x509.random_serial_number()
|
|
).not_valid_before(
|
|
datetime.now()
|
|
).not_valid_after(
|
|
datetime.now() + timedelta(days=365)
|
|
).sign(
|
|
private_key=private_key,
|
|
algorithm=hashes.SHA256(),
|
|
)
|
|
self.sudo().l10n_be_intervat_certificate_id = self.env['certificate.certificate'].sudo().create([{
|
|
'name': f"Intervat Certificate: {self.name}",
|
|
'content': base64.b64encode(certificate.public_bytes(encoding=serialization.Encoding.PEM)),
|
|
'private_key_id': self.l10n_be_intervat_private_key.id,
|
|
'company_id': self.id,
|
|
}])
|
|
|
|
self.sudo().l10n_be_intervat_code_verifier = ''.join([secrets.choice(ALLOWED_CHARS) for _ in range(60)])
|
|
sha256_hash = hashlib.sha256(self.l10n_be_intervat_code_verifier.encode()).digest()
|
|
code_challenge = base64.urlsafe_b64encode(sha256_hash).rstrip(b'=').decode()
|
|
self.sudo().l10n_be_intervat_code_challenge = code_challenge
|
|
|
|
jwk = self._l10n_be_generate_jwk()
|
|
params = {
|
|
**jwk,
|
|
'company_token': hashlib.sha256(self.l10n_be_intervat_certificate_id.sudo().l10n_be_intervat_jwk_token.encode()).hexdigest(),
|
|
'db_uuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid'),
|
|
'root_url': request.httprequest.root_url,
|
|
}
|
|
response = requests.post(
|
|
url=f"{IAP_ENDPOINT[self.l10n_be_intervat_mode]}/register_jwk",
|
|
params=params,
|
|
timeout=L10N_BE_INTERVAT_TOKEN_TIMEOUT_LIMIT,
|
|
)
|
|
if not response.ok:
|
|
if error_message := IAP_ERROR_MESSAGE.get(response.text):
|
|
raise UserError(error_message)
|
|
raise UserError(self.env._("Error while generating the certificate."))
|
|
|
|
def _l10n_be_generate_jwt(self):
|
|
""" Generate a new jwt to send to the government apis. This jwt is signed with the x509 certificate private key. """
|
|
if jwt is None:
|
|
_logger.error("The library 'PyJWT' is missing, cannot generate a new jwt.")
|
|
return ''
|
|
|
|
private_key = serialization.load_pem_private_key(
|
|
base64.b64decode(self.l10n_be_intervat_private_key.sudo().pem_key),
|
|
None
|
|
)
|
|
expiration_time = datetime.now(timezone.utc) + timedelta(minutes=1)
|
|
expiration_timestamp = int(expiration_time.timestamp())
|
|
header, payload = {
|
|
'alg': 'RS256',
|
|
'typ': 'JWT',
|
|
'kid': self.l10n_be_intervat_certificate_id.sudo().l10n_be_intervat_jwk_kid,
|
|
}, {
|
|
'iss': 'odoo',
|
|
'sub': 'odoo',
|
|
'aud': TOKEN_ENDPOINT[self.l10n_be_intervat_mode],
|
|
'exp': expiration_timestamp,
|
|
'jti': secrets.token_hex(16),
|
|
}
|
|
return jwt.encode(payload, private_key, headers=header, algorithm=header['alg'])
|
|
|
|
def _l10n_be_generate_jwk(self):
|
|
""" Generate one jwk to be sent to IAP to be stored in the jwks url. """
|
|
def to_base64url(b64_bytes):
|
|
b64_str = b64_bytes.replace(b'\n', b'').replace(b'\r', b'')
|
|
raw = base64.b64decode(b64_str)
|
|
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
|
|
|
|
pem_certificate = base64.b64decode(self.l10n_be_intervat_certificate_id.sudo().pem_certificate)
|
|
cert = x509.load_pem_x509_certificate(pem_certificate, default_backend())
|
|
cert_der = cert.public_bytes(encoding=Encoding.DER)
|
|
x5c_value = self.l10n_be_intervat_certificate_id.sudo()._get_der_certificate_bytes().decode()
|
|
sha1_thumbprint = hashlib.sha1(cert_der).digest()
|
|
x5t_value = base64.urlsafe_b64encode(sha1_thumbprint)
|
|
e, n = self.l10n_be_intervat_certificate_id.sudo()._get_public_key_numbers_bytes()
|
|
|
|
return {
|
|
"key_type": 'RSA',
|
|
"key_id": self.l10n_be_intervat_certificate_id.sudo().l10n_be_intervat_jwk_kid,
|
|
"key_use": 'sig',
|
|
"certificate_thumbprint": x5t_value.decode().rstrip('='),
|
|
"certificate_chain": x5c_value.rstrip('\n'),
|
|
"key_modulus": to_base64url(n),
|
|
"key_exponent": to_base64url(e),
|
|
}
|
|
|
|
def _l10n_be_refresh_token(self):
|
|
""" Retrieve new tokens to call the apis. Access tokens are one shot tokens, so we have
|
|
to get a new one before each call.
|
|
|
|
:return: True if tokens has been generated, else False.
|
|
"""
|
|
if not self.env.user.has_group("account.group_account_user"):
|
|
raise AccessError(self.env._("Only an accountant can perform this action."))
|
|
|
|
if not self._l10n_be_intervat_is_authentication_valid():
|
|
return False
|
|
|
|
params = {
|
|
'company_token': hashlib.sha256(self.l10n_be_intervat_certificate_id.sudo().l10n_be_intervat_jwk_token.encode()).hexdigest(),
|
|
'db_uuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid'),
|
|
}
|
|
iap_response = requests.post(
|
|
url=f"{IAP_ENDPOINT[self.l10n_be_intervat_mode]}/register_jwk",
|
|
params=params,
|
|
timeout=L10N_BE_INTERVAT_TOKEN_TIMEOUT_LIMIT,
|
|
)
|
|
if not iap_response.ok:
|
|
raise UserError(self.env._("Error while contacting IAP."))
|
|
|
|
response = requests.post(
|
|
url=TOKEN_ENDPOINT[self.l10n_be_intervat_mode],
|
|
data={
|
|
'grant_type': 'refresh_token',
|
|
'refresh_token': self.l10n_be_intervat_refresh_token,
|
|
'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
|
|
'client_assertion': self._l10n_be_generate_jwt(),
|
|
},
|
|
headers={
|
|
'accept': 'application/json',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
},
|
|
timeout=L10N_BE_INTERVAT_TOKEN_TIMEOUT_LIMIT,
|
|
)
|
|
response_json, error = self._l10n_be_get_error_from_response(response)
|
|
|
|
if error:
|
|
raise UserError(error['error_message'])
|
|
|
|
self._l10n_be_verify_id_token_signature(response_json['id_token'])
|
|
self.sudo().l10n_be_intervat_access_token = response_json['access_token']
|
|
self.sudo().l10n_be_intervat_refresh_token = response_json['refresh_token']
|
|
|
|
# In case an error occurs, we need to commit the tokens in the db as they're about to be used in the calling request
|
|
if not tools.config['test_enable'] and not modules.module.current_test:
|
|
self.env.cr.commit()
|
|
|
|
return True
|
|
|
|
def _l10n_be_verify_id_token_signature(self, id_token):
|
|
""" Check the signature of the jwt sent by the government. """
|
|
if jwt is None:
|
|
raise UserError(self.env._("The library 'PyJWT' is missing, cannot verify the jwt signature."))
|
|
|
|
jwt_header = jwt.get_unverified_header(id_token)
|
|
kid = jwt_header.get('kid')
|
|
response = requests.get(
|
|
url=JWKS_ENDPOINT[self.l10n_be_intervat_mode],
|
|
timeout=L10N_BE_INTERVAT_TOKEN_TIMEOUT_LIMIT,
|
|
)
|
|
response_json, error = self._l10n_be_get_error_from_response(response)
|
|
if error:
|
|
raise UserError(error['error_message'])
|
|
jwk = next((jwk for jwk in response_json['keys'] if jwk.get('kid') == kid), None)
|
|
if not jwk:
|
|
raise UserError(self.env._("Impossible to check the response signature."))
|
|
|
|
cert = x509.load_der_x509_certificate(base64.b64decode(jwk['x5c'][0]), default_backend())
|
|
public_key = cert.public_key()
|
|
try:
|
|
decoded_jwt = jwt.decode(
|
|
jwt=id_token,
|
|
algorithms='RS256',
|
|
key=public_key,
|
|
audience='odoo',
|
|
)
|
|
except InvalidSignatureError:
|
|
raise UserError(self.env._("The signature of the JWT is not matching."))
|
|
|
|
except ImmatureSignatureError:
|
|
raise UserError(self.env._("The token is not valid yet."))
|
|
|
|
return decoded_jwt
|
|
|
|
########################################
|
|
# Actions #
|
|
########################################
|
|
|
|
def _l10n_be_intervat_authentication_action(self, return_id, request_type):
|
|
self._l10n_be_intervat_check_activated()
|
|
self._l10n_be_generate_private_key_and_certificate()
|
|
return {
|
|
'type': 'ir.actions.act_url',
|
|
'url': f'/l10n_be_intervat/authorize/{self.id}/{return_id}/{request_type}',
|
|
'target': 'self',
|
|
}
|
|
|
|
########################################
|
|
# Api's call #
|
|
########################################
|
|
|
|
def _l10n_be_fetch_document_from_myminfin(self, document_uuid):
|
|
""" Fetch one document from the MyMinFin api. Documents could either be xml or pdf and represent
|
|
a declaration. Documents are only available the day after the declaration.
|
|
|
|
:param document_uuid: UUID of the document to fetch. This id is sent back when submitting
|
|
a declaration.
|
|
|
|
:return: Response from MyMinFin api with file content. Return -1 if refresh token is expired.
|
|
"""
|
|
url = f"{BASE_URL[self.l10n_be_intervat_mode]}/FineAPI/Generic/OAU/v2/documents/{document_uuid}/content"
|
|
auth_url_params = urlencode({
|
|
'ownerType': 'CBE',
|
|
'ownerIdentifier': re.sub(r'[^0-9]', '', self.vat),
|
|
})
|
|
if not self._l10n_be_refresh_token():
|
|
return None
|
|
|
|
url += f"?{auth_url_params}"
|
|
|
|
response = requests.get(
|
|
url=url,
|
|
headers={
|
|
'Authorization': f'Bearer {self.l10n_be_intervat_access_token}',
|
|
'Minfin-Ws-Correlation': f"{uuid.uuid4()}",
|
|
},
|
|
timeout=L10N_BE_INTERVAT_TOKEN_TIMEOUT_LIMIT,
|
|
)
|
|
response_content, error = self._l10n_be_get_error_from_response(response, parse_json=False)
|
|
if error:
|
|
raise UserError(error['error_message'])
|
|
return response_content
|
|
|
|
def _l10n_be_post_vat_declaration(self, xml_content: bytes, file_name: str):
|
|
""" Submit a vat return xml to the Intervat api. The format of the file is a zip file with only
|
|
one xml file in it as it's a technical requirement asked by the api.
|
|
|
|
:param xml_content: Content of the xml file from the tax report.
|
|
:param file_name: File name of the xml file to be added in the zip file.
|
|
|
|
:return: Response from api with pdf and xml UUID, which can be used to fetch related documents from
|
|
MyMinFin API. Return None if refresh token is expired.
|
|
"""
|
|
url = f"{BASE_URL[self.l10n_be_intervat_mode]}/Intervat/api/OAU/v1/declaration/vat/{re.sub(r'[^0-9]', '', self.vat)}"
|
|
if not self._l10n_be_refresh_token():
|
|
return None
|
|
|
|
with io.BytesIO() as buffer:
|
|
with zipfile.ZipFile(buffer, 'w', compression=zipfile.ZIP_DEFLATED) as zipfile_obj:
|
|
zipfile_obj.writestr(file_name, xml_content)
|
|
|
|
response = requests.post(
|
|
url=url,
|
|
data=buffer.getvalue(),
|
|
headers={
|
|
'Authorization': f'Bearer {self.l10n_be_intervat_access_token}',
|
|
'Content-Type': 'application/zip',
|
|
},
|
|
timeout=L10N_BE_INTERVAT_TOKEN_TIMEOUT_LIMIT,
|
|
)
|
|
response_json, error = self._l10n_be_get_error_from_response(response)
|
|
if error:
|
|
raise UserError(error['error_message'])
|
|
return {
|
|
'pdfReference': response_json['pdfReference'],
|
|
'xmlReference': response_json['xmlReference'],
|
|
}
|
|
|
|
def _l10n_be_get_error_from_response(self, response, parse_json=True):
|
|
""" Check if the response from Intervat is valid or not.
|
|
|
|
:param response: Response from Intervat
|
|
:param parse_json: Whenever we want to parse the response from Intervat or not. We don't want
|
|
to parse the response if the request is meant to fetch a document.
|
|
|
|
:return: tuple(formatted response, errors).
|
|
"""
|
|
try:
|
|
formatted_response = response.json() if parse_json else response.content
|
|
response.raise_for_status()
|
|
return formatted_response, {}
|
|
|
|
except JSONDecodeError as error:
|
|
return {}, {'error_message': error.msg, 'error_code': response.status_code}
|
|
|
|
except requests.exceptions.HTTPError as error:
|
|
response_json = response.json()
|
|
if 'businessrules' in response_json:
|
|
error_message = ""
|
|
for rule in response_json['businessrules']:
|
|
lang_prefix = self.env.lang[:2] if self.env.lang[:2] in ('nl', 'fr', 'de', 'en') else 'en'
|
|
error_message += f"{rule['descriptions'][lang_prefix]}\n"
|
|
return {}, {'error_message': error_message, 'error_code': response.status_code}
|
|
|
|
if 'detail' in response_json:
|
|
error_message = response_json['detail']
|
|
if not parse_json and response.status_code == 403:
|
|
error_message += "\n" + self.env._("If you just submitted your declaration, you might need to wait until tomorrow to fetch the documents. You can still retrieve them from the Intervat Portal.")
|
|
return {}, {'error_message': error_message, 'error_code': response.status_code}
|
|
|
|
if response_json.get('code') == 'InboundAuthorizationFailure':
|
|
# This error means the user didn't give the right consent, so we delete the access_token
|
|
# to close the connection
|
|
self.l10n_be_intervat_access_token = None
|
|
return {}, {'error_message': (Markup("""
|
|
<p>%(msg)s:</p>
|
|
<ul>
|
|
<li>Documents-Read API (MMF-API)</li>
|
|
<li>VAT-Manage API (INT-API)</li>
|
|
</ul>
|
|
""") % {
|
|
'msg': escape(self.env._("Your authentication with Intervat was incomplete. Please authenticate again and make sure you grant consent to both of the following"))
|
|
}), 'error_code': response.status_code}
|
|
|
|
return {}, {'error_message': error.response.content.decode(), 'error_code': response.status_code}
|
|
|
|
########################################
|
|
# Utils #
|
|
########################################
|
|
|
|
def _l10n_be_intervat_is_authentication_valid(self):
|
|
return bool(self.l10n_be_intervat_access_token)
|
|
|
|
def _l10n_be_intervat_check_activated(self):
|
|
if self.l10n_be_intervat_mode == 'disabled':
|
|
raise RedirectWarning(
|
|
self.env._('Intervat is not activated. You can activate it in the settings.'),
|
|
'account.action_account_config',
|
|
self.env._('Set Intervat mode'),
|
|
)
|
|
|
|
def _cron_update_refresh_token(self):
|
|
for company in self.search([('l10n_be_intervat_access_token', '!=', False), ('l10n_be_intervat_mode', '!=', 'disabled')]):
|
|
company.lock_for_update()
|
|
company._l10n_be_refresh_token()
|