Files
enterprise-addons/l10n_be_hr_payroll_dimona/models/hr_version.py
T

364 lines
18 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import json
import jwt
import re
import secrets
import string
import time
from cryptography.hazmat.primitives import serialization
from datetime import timedelta
from requests import request
from requests.exceptions import HTTPError
from werkzeug.urls import url_quote
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import file_open
with file_open('l10n_be_hr_payroll_dimona/data/api_data.json') as f_api_data:
API_DATA = json.load(f_api_data)
API_ROUTES = API_DATA['routes']['production']
DIMONA_TIMEOUT = 30
class HrVersion(models.Model):
_inherit = 'hr.version'
# YTI TODO: Drop those 3 fields in master
l10n_be_dimona_in_declaration_number = fields.Char(
string="Dimona Declaration Number", groups="hr_payroll.group_hr_payroll_user", tracking=True
)
l10n_be_dimona_last_declaration_number = fields.Char(
string="Dimona Last Declaration Number", groups="hr_payroll.group_hr_payroll_user", tracking=True
)
l10n_be_dimona_declaration_state = fields.Selection(
string="Dimona Declaration State",
selection=[
('none', 'Not Declared'),
('waiting', 'Declared and waiting status'),
('done', 'Declared and accepted'),
('done_warning', 'Declared and accepted with warnings'),
('refused', 'Declared and refused'),
('waiting_sigedis', 'Declared and waiting Sigedis'),
('error', "Invalid declaration or restricted access"),
], default='none', groups="hr_payroll.group_hr_payroll_user", tracking=True)
l10n_be_dimona_planned_hours = fields.Integer("Student Planned Hours", groups="hr_payroll.group_hr_payroll_user", tracking=True)
l10n_be_is_student = fields.Boolean(compute='_compute_l10n_be_is_student', groups="hr_payroll.group_hr_payroll_user")
@api.depends('structure_type_id')
def _compute_l10n_be_is_student(self):
student_stuct_type = self.env.ref('l10n_be_hr_payroll.structure_type_student')
for version in self:
version.l10n_be_is_student = version.structure_type_id == student_stuct_type
def _validate_response(self, key, response):
response_json = response.json()
expected_format = API_DATA['response_validation'][key]['response']
TYPE_MAP = {
"str": str,
"int": int,
"list": list,
"dict": dict,
"float": float,
"bool": bool,
}
if response.status_code != API_DATA['response_validation'][key]['status']:
return response_json
if len(response_json) != len(expected_format):
raise UserError(_(' The API response is not in the expected format. Please contact an administrator.'))
for field, field_type in expected_format.items():
if field not in response_json:
raise UserError(_(' The API response is not containing the required fields. Please contact an administrator.'))
if not isinstance(response_json[field], TYPE_MAP[field_type]):
raise UserError(_(' The API response have an unexpected type. Please contact an administrator.'))
return response_json
def _get_jwt(self, company):
expeditor_number = company.onss_expeditor_number
if not expeditor_number:
raise UserError(_('No expeditor number defined on the payroll settings.'))
if expeditor_number.isdigit():
expeditor = API_DATA['jwt']['user'] % (company.onss_expeditor_number)
else:
expeditor = expeditor_number
certificate_sudo = company.sudo().onss_certificate_id
if not certificate_sudo:
raise UserError(_('No Certificate defined on the Payroll Configuration'))
unique_id = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(20))
now = int(time.time())
payload = {
# Unique jwt indentifier
"jti": unique_id,
# App supplying the jwt
"iss": expeditor,
# Main jwt subject
"sub": expeditor,
# jwt receiver (audience)
"aud": API_DATA['jwt']['audiance'],
# Expiration
"exp": now + API_DATA['jwt']['expires_in'],
# Timestamp before accepting jwt
"nbf": now,
# Creation timestamp
"iat": now,
}
try:
pem_key = certificate_sudo.private_key_id.pem_key
password = certificate_sudo.private_key_id.password
private_key = serialization.load_pem_private_key(
base64.b64decode(pem_key),
password=password.encode() if password else None,
)
bearer_token = jwt.encode(payload, private_key, algorithm="RS256")
except ValueError as e:
raise UserError(_('Error on authentication. Please contact an administrator. (%s)', e))
return bearer_token
def _dimona_authenticate(self, company, declare=True):
bearer = self._get_jwt(company)
data = {
'grant_type': 'client_credentials',
'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
'scope': 'scope:dimona:declaration:declarant' if declare else 'scope:dimona:declaration:consult',
'client_assertion': bearer,
}
headers = {
'Content-Type': 'application/x-www-form-urlencoded',
}
try:
response = request(**API_ROUTES['authentification'], data=data, headers=headers, timeout=DIMONA_TIMEOUT)
except HTTPError as e:
raise UserError(_('Cannot connect with the ONSS servers. Please contact an administrator. (%s)', e))
if response.status_code == 200:
return self._validate_response('authentification', response)['access_token']
if response.status_code == 400:
raise UserError(_('Error with one or several invalid parameters on the POST request during authentication. Please contact an administrator. (%s)', response.text))
if response.status_code == 500:
raise UserError(_('Due to a technical problem at the ONSS side, the authentication could not be done by the ONSS.'))
response.raise_for_status()
def _dimona_declaration(self, data):
self.ensure_one()
access_token = self._dimona_authenticate(self.company_id)
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer %s' % access_token,
}
try:
response = request(**API_ROUTES['push_declaration'], json=data, headers=headers, timeout=DIMONA_TIMEOUT)
except HTTPError as e:
raise UserError(_('Cannot connect with the ONSS servers. Please contact an administrator. (%s)', e))
if response.status_code == 201:
result = response.headers
self.l10n_be_dimona_last_declaration_number = result['Location'].split('/')[-1]
if 'dimonaIn' in data:
self.l10n_be_dimona_in_declaration_number = self.l10n_be_dimona_last_declaration_number
self.l10n_be_dimona_declaration_state = 'waiting'
self.message_post(body=_('DIMONA IN declaration posted successfully, waiting validation'))
self.env.ref('l10n_be_hr_payroll_dimona.ir_cron_check_dimona')._trigger(fields.Datetime.now() + timedelta(minutes=1))
return
if response.status_code == 400:
raise UserError(_('Error with one or several invalid parameters on the POST request. Please contact an administrator. (%s)', response.text))
if response.status_code == 401:
raise UserError(_('The authentication token is invalid. Please contact an administrator. (%s)', response.text))
if response.status_code == 403:
raise UserError(_('Your user does not have the rights to make a declaration for the employer. This happens, for example, if the user does not have or no longer has a mandate for the employer. (%s)', response.text))
if response.status_code == 500:
raise UserError(_('Due to a technical problem at the ONSS side, the Dimona declaration could not be received by the ONSS.'))
response.raise_for_status()
def _action_open_dimona(self, foreigner=False):
self.ensure_one()
onss_registration_number = self.company_id.onss_registration_number
if not onss_registration_number:
raise UserError(_('No ONSS registration number is defined for company %s', self.company_id.name))
niss = self.employee_id.niss
if not foreigner and not self.employee_id._is_niss_valid():
raise UserError(_('The NISS is invalid.'))
first_name, last_name = self.employee_id._get_split_name()
if not first_name or not last_name:
raise UserError(_('The employee name is incomplete'))
if foreigner and not all(self.employee_id[field] for field in ['birthday', 'place_of_birth', 'country_of_birth', 'country_id', 'sex']):
raise UserError(_("Foreigner employees should provide their name, birthdate, birth place, birth country, nationality and sex"))
if foreigner and not all(self.employee_id[f'private_{field}'] for field in ['street', 'zip', 'city', 'country_id']):
raise UserError(_("Foreigner employees should provide a complete address (street, number, zip, city, country)"))
if not foreigner and self.employee_id.private_zip not in API_DATA['data']['municipality_by_postal_code']:
raise UserError(_("The employee zip does not exist."))
if not self.employee_id.sex:
raise UserError(_("The employee sex is not specified."))
if self.employee_id.private_street:
street_digits = re.findall(r"[0-9]+", self.employee_id.private_street)
if not street_digits:
raise UserError(_('No house number found on employee street'))
house_number = street_digits[0]
else:
house_number = False
data = {
"employer": {
"employerId": int(onss_registration_number),
},
"worker": {
"ssin": niss if not foreigner else False,
'familyName': last_name,
'givenName': first_name,
'birthDate': (self.employee_id.birthday or fields.Date.today()).strftime("%Y-%m-%d"),
'placeOfBirth': (self.employee_id.place_of_birth or '').upper(),
'countryOfBirth': API_DATA['data']['country_code_by_alpha2'].get(self.employee_id.country_of_birth.code),
'nationality': API_DATA['data']['country_code_by_alpha2'].get(self.employee_id.country_id.code),
'gender': API_DATA['data']['code_by_sex'].get(self.employee_id.sex, 0),
'address': {
'street': self.employee_id.private_street,
'houseNumber': house_number,
'postCode': self.employee_id.private_zip,
'municipality': {
'name': self.employee_id.private_city,
'code': API_DATA['data']['municipality_by_city_name'].get(self.employee_id.private_city.lower())
or API_DATA['data']['municipality_by_postal_code'].get(self.employee_id.private_zip) or 99999,
},
'country': API_DATA['data']['country_code_by_alpha2'].get(self.employee_id.private_country_id.code)
},
},
"dimonaIn": {
"startDate": self.contract_date_start.strftime("%Y-%m-%d"),
"features": {
"jointCommissionNumber": "XXX",
"workerType": "OTH" if not self.l10n_be_is_student else "STU"
}
}
}
if self.contract_date_end:
data['dimonaIn']["endDate"] = self.contract_date_end.strftime("%Y-%m-%d")
if self.l10n_be_dimona_planned_hours:
data['dimonaIn']["plannedHoursNumber"] = self.l10n_be_dimona_planned_hours
# Drop empty worker informations (The ONSS doesn't like it)
data['worker']['address'] = {key: value for key, value in data['worker']['address'].items() if value}
data['worker'] = {key: value for key, value in data['worker'].items() if value or key == 'gender'}
self._dimona_declaration(data)
def _action_close_dimona(self):
self.ensure_one()
data = {
"dimonaOut": {
"periodId": int(self.l10n_be_dimona_in_declaration_number),
"endDate": self.contract_date_end.strftime("%Y-%m-%d"),
}
}
self._dimona_declaration(data)
def _action_update_dimona(self):
self.ensure_one()
data = {
"dimonaUpdate": {
"periodId": int(self.l10n_be_dimona_in_declaration_number),
"startDate": self.contract_date_start.strftime("%Y-%m-%d")
}
}
if self.contract_date_end:
data["dimonaUpdate"]["endDate"] = self.contract_date_end.strftime("%Y-%m-%d")
if self.l10n_be_dimona_planned_hours:
data['dimonaUpdate']["plannedHoursNumber"] = self.l10n_be_dimona_planned_hours
self._dimona_declaration(data)
def _action_cancel_dimona(self):
self.ensure_one()
data = {
"dimonaCancel": {
"periodId": int(self.l10n_be_dimona_in_declaration_number),
}
}
self._dimona_declaration(data)
def action_check_dimona(self):
self.ensure_one()
if not self.env.user.has_group('hr_payroll.group_hr_payroll_user'):
raise UserError(_("You don't have the right to call this action"))
if not self.l10n_be_dimona_last_declaration_number:
raise UserError(_("No DIMONA declaration is linked to this contract"))
access_token = self._dimona_authenticate(self.company_id, declare=False)
headers = {
'Content-Type': 'application/json',
'Authorization': 'Bearer %s' % access_token,
}
try:
response = request(
API_ROUTES['get_declaration']['method'],
API_ROUTES['get_declaration']['url'] + url_quote(self.l10n_be_dimona_last_declaration_number),
headers=headers, timeout=DIMONA_TIMEOUT)
if response.status_code == 200:
result = response.json()
status = result['declarationStatus']['result']
if status == 'A':
self.l10n_be_dimona_declaration_state = 'done'
self.message_post(body=_('DIMONA declaration treated and accepted without anomalies'))
elif status == 'W':
self.l10n_be_dimona_declaration_state = 'done_warning'
self.message_post(body=_(
'DIMONA declaration treated and accepted with non blocking anomalies\n%(anomalies)s\n%(informations)s',
anomalies=result['declarationStatus']['anomaliesCollection'],
informations=result['declarationStatus']['informationsCollection']))
elif status == 'B':
self.l10n_be_dimona_declaration_state = 'refused'
self.message_post(body=_(
'DIMONA declaration treated and refused (blocking anomalies)\n%s',
result['declarationStatus']['anomaliesCollection']))
elif status == 'S':
self.l10n_be_dimona_declaration_state = 'waiting_sigedis'
self.message_post(body=_('DIMONA declaration waiting worker identification by Sigedis'))
elif response.status_code == 400:
raise UserError(_('Error with one or several invalid parameters on the POST request. Please contact an administrator. (%s)', response.text))
elif response.status_code == 403:
raise UserError(_('Your user does not have the rights to consult this declaration. This happens, for example, if the user does not have or no longer has a mandate for the employer. (%s)', response.text))
elif response.status_code == 404:
raise UserError(_('The declaration has been submitted but not processed yet or the declaration reference is not known. (%s)', response.text))
elif response.status_code == 500:
raise UserError(_('Due to a technical problem at the ONSS side, the Dimona declaration could not be received by the ONSS.'))
response.raise_for_status()
except HTTPError as e:
raise UserError(_('Cannot connect with the ONSS servers. Please contact an administrator. (%s)', e))
@api.model
def _cron_l10n_be_check_dimona(self, batch_size=50):
contracts = self.search([
('l10n_be_dimona_declaration_state', '=', 'waiting'),
])
if not contracts:
return False
contracts_batch = contracts[:batch_size]
for contract in contracts_batch:
try:
# In case the ONSS is not available of if this is the declaration is not
# processed yet fall silently to allow checking all the contracts of the batch
contract.action_check_dimona()
except Exception: # noqa: BLE001
contract.l10n_be_dimona_declaration_state = 'error'
# if necessary, retrigger the cron to generate more pdfs
if len(contracts) > batch_size:
self.env.ref('l10n_be_hr_payroll_dimona.ir_cron_check_dimona')._trigger()
return True
return False