[REF] payment_sips: migrate Sips to the new payment API
See the merge commit for more details. task-2333041
This commit is contained in:
committed by
Antoine Vandevenne (anv)
parent
b14ce986ce
commit
a9ac72d821
@@ -1,7 +1,11 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import models
|
||||
from . import controllers
|
||||
from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers
|
||||
from odoo.addons.payment import reset_payment_provider
|
||||
|
||||
from odoo.addons.payment import reset_payment_acquirer
|
||||
from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook
|
||||
|
||||
|
||||
def uninstall_hook(cr, registry):
|
||||
reset_payment_provider(cr, registry, 'sips')
|
||||
reset_payment_acquirer(cr, registry, 'sips')
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
# -*- encoding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
# Copyright 2015 Eezee-It
|
||||
# Original Copyright 2015 Eezee-It, modified and maintained by Odoo.
|
||||
|
||||
{
|
||||
'name': 'Worldline SIPS',
|
||||
'version': '1.1',
|
||||
'author': 'Eezee-It',
|
||||
'version': '2.0',
|
||||
'category': 'Accounting/Payment Acquirers',
|
||||
'sequence': 385,
|
||||
'description': """
|
||||
@@ -21,8 +18,7 @@ not guaranteed.""",
|
||||
'views/payment_sips_templates.xml',
|
||||
'data/payment_acquirer_data.xml',
|
||||
],
|
||||
'installable': True,
|
||||
'application': True,
|
||||
'post_init_hook': 'create_missing_journal_for_acquirers',
|
||||
'post_init_hook': 'create_missing_journals',
|
||||
'uninstall_hook': 'uninstall_hook',
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import main
|
||||
|
||||
@@ -1,50 +1,57 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
# Original Copyright 2015 Eezee-It, modified and maintained by Odoo.
|
||||
|
||||
# Copyright 2015 Eezee-It
|
||||
|
||||
import json
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
import werkzeug
|
||||
|
||||
from odoo import http
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SipsController(http.Controller):
|
||||
_notify_url = '/payment/sips/ipn/'
|
||||
_return_url = '/payment/sips/dpn/'
|
||||
_notify_url = '/payment/sips/ipn/'
|
||||
|
||||
def sips_validate_data(self, **post):
|
||||
sips = request.env['payment.acquirer'].search([('provider', '=', 'sips')], limit=1)
|
||||
security = sips.sudo()._sips_generate_shasign(post)
|
||||
if security == post['Seal']:
|
||||
_logger.debug('Sips: validated data')
|
||||
return request.env['payment.transaction'].sudo().form_feedback(post, 'sips')
|
||||
_logger.warning('Sips: data are corrupted')
|
||||
return False
|
||||
|
||||
@http.route('/payment/sips/ipn/', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
def sips_ipn(self, **post):
|
||||
""" Sips IPN. """
|
||||
_logger.info('Beginning Sips IPN form_feedback with post data %s', pprint.pformat(post)) # debug
|
||||
if not post:
|
||||
# SIPS sometimes sends empty notifications, the reason why is
|
||||
# unclear but they tend to pollute logs and do not provide any
|
||||
# meaningful information; log as a warning instead of a traceback
|
||||
_logger.warning('Sips: received empty notification; skip.')
|
||||
else:
|
||||
self.sips_validate_data(**post)
|
||||
return ''
|
||||
|
||||
@http.route('/payment/sips/dpn', type='http', auth="public", methods=['POST'], csrf=False)
|
||||
@http.route(_return_url, type='http', auth='public', methods=['POST'], csrf=False)
|
||||
def sips_dpn(self, **post):
|
||||
""" Sips DPN """
|
||||
_logger.info("beginning Sips DPN _handle_feedback_data with data %s", pprint.pformat(post))
|
||||
try:
|
||||
_logger.info('Beginning Sips DPN form_feedback with post data %s', pprint.pformat(post)) # debug
|
||||
self.sips_validate_data(**post)
|
||||
except:
|
||||
if self._sips_validate_data(post):
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data('sips', post)
|
||||
except ValidationError:
|
||||
pass
|
||||
return werkzeug.utils.redirect('/payment/process')
|
||||
return werkzeug.utils.redirect('/payment/status')
|
||||
|
||||
@http.route(_notify_url, type='http', auth='public', methods=['POST'], csrf=False)
|
||||
def sips_ipn(self, **post):
|
||||
""" Sips IPN. """
|
||||
_logger.info("beginning Sips IPN _handle_feedback_data with data %s", pprint.pformat(post))
|
||||
if not post:
|
||||
# SIPS sometimes sends empty notifications, the reason why is unclear but they tend to
|
||||
# pollute logs and do not provide any meaningful information; log as a warning instead
|
||||
# of a traceback.
|
||||
_logger.warning("received empty notification; skip.")
|
||||
else:
|
||||
try:
|
||||
if self._sips_validate_data(post):
|
||||
request.env['payment.transaction'].sudo()._handle_feedback_data('sips', post)
|
||||
except ValidationError:
|
||||
pass # Acknowledge the notification to avoid getting spammed
|
||||
return ''
|
||||
|
||||
def _sips_validate_data(self, post):
|
||||
tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data('sips', post)
|
||||
acquirer_sudo = tx_sudo.acquirer_id
|
||||
security = acquirer_sudo._sips_generate_shasign(post['Data'])
|
||||
if security == post['Seal']:
|
||||
_logger.debug('validated data')
|
||||
return True
|
||||
else:
|
||||
_logger.warning('data are tampered')
|
||||
return False
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="payment.payment_acquirer_sips" model="payment.acquirer">
|
||||
<field name="name">Sips</field>
|
||||
<field name="image_128" type="base64" file="payment_sips/static/src/img/sips_icon.png"/>
|
||||
<field name="provider">sips</field>
|
||||
<field name="company_id" ref="base.main_company"/>
|
||||
<field name="view_template_id" ref="payment_sips.sips_form"/>
|
||||
</record>
|
||||
<record id="payment.payment_acquirer_sips" model="payment.acquirer">
|
||||
<field name="provider">sips</field>
|
||||
<field name="redirect_form_view_id" ref="redirect_form"/>
|
||||
<field name="support_authorization">False</field>
|
||||
<field name="support_fees_computation">False</field>
|
||||
<field name="support_tokenization">False</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import payment
|
||||
from . import payment_acquirer
|
||||
from . import payment_transaction
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
|
||||
from collections import namedtuple
|
||||
|
||||
Currency = namedtuple('Currency', ['iso_id', 'decimal'])
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
# ISO 4217 Data for currencies supported by sips
|
||||
# NOTE: these are listed on the Atos Wordline SIPS POST documentation page
|
||||
@@ -12,52 +9,63 @@ Currency = namedtuple('Currency', ['iso_id', 'decimal'])
|
||||
# restrictive, I'm gonna assume they are supported when using the right flow
|
||||
# and payment methods, which may not work in SIMU...
|
||||
# Since SIPS advises to use 'in production', well...
|
||||
SIPS_SUPPORTED_CURRENCIES = {
|
||||
'ARS': Currency('032', 2),
|
||||
'AUD': Currency('036', 2),
|
||||
'BHD': Currency('048', 3),
|
||||
'KHR': Currency('116', 2),
|
||||
'CAD': Currency('124', 2),
|
||||
'LKR': Currency('144', 2),
|
||||
'CNY': Currency('156', 2),
|
||||
'HRK': Currency('191', 2),
|
||||
'CZK': Currency('203', 2),
|
||||
'DKK': Currency('208', 2),
|
||||
'HKD': Currency('344', 2),
|
||||
'HUF': Currency('348', 2),
|
||||
'ISK': Currency('352', 0),
|
||||
'INR': Currency('356', 2),
|
||||
'ILS': Currency('376', 2),
|
||||
'JPY': Currency('392', 0),
|
||||
'KRW': Currency('410', 0),
|
||||
'KWD': Currency('414', 3),
|
||||
'MYR': Currency('458', 2),
|
||||
'MUR': Currency('480', 2),
|
||||
'MXN': Currency('484', 2),
|
||||
'NPR': Currency('524', 2),
|
||||
'NZD': Currency('554', 2),
|
||||
'NOK': Currency('578', 2),
|
||||
'QAR': Currency('634', 2),
|
||||
'RUB': Currency('643', 2),
|
||||
'SAR': Currency('682', 2),
|
||||
'SGD': Currency('702', 2),
|
||||
'ZAR': Currency('710', 2),
|
||||
'SEK': Currency('752', 2),
|
||||
'CHF': Currency('756', 2),
|
||||
'THB': Currency('764', 2),
|
||||
'AED': Currency('784', 2),
|
||||
'TND': Currency('788', 3),
|
||||
'GBP': Currency('826', 2),
|
||||
'USD': Currency('840', 2),
|
||||
'TWD': Currency('901', 2),
|
||||
'RSD': Currency('941', 2),
|
||||
'RON': Currency('946', 2),
|
||||
'TRY': Currency('949', 2),
|
||||
'XOF': Currency('952', 0),
|
||||
'XPF': Currency('953', 0),
|
||||
'BGN': Currency('975', 2),
|
||||
'EUR': Currency('978', 2),
|
||||
'UAH': Currency('980', 2),
|
||||
'PLN': Currency('996', 2),
|
||||
'BRL': Currency('986', 2),
|
||||
SUPPORTED_CURRENCIES = {
|
||||
'ARS': '032',
|
||||
'AUD': '036',
|
||||
'BHD': '048',
|
||||
'KHR': '116',
|
||||
'CAD': '124',
|
||||
'LKR': '144',
|
||||
'CNY': '156',
|
||||
'HRK': '191',
|
||||
'CZK': '203',
|
||||
'DKK': '208',
|
||||
'HKD': '344',
|
||||
'HUF': '348',
|
||||
'ISK': '352',
|
||||
'INR': '356',
|
||||
'ILS': '376',
|
||||
'JPY': '392',
|
||||
'KRW': '410',
|
||||
'KWD': '414',
|
||||
'MYR': '458',
|
||||
'MUR': '480',
|
||||
'MXN': '484',
|
||||
'NPR': '524',
|
||||
'NZD': '554',
|
||||
'NOK': '578',
|
||||
'QAR': '634',
|
||||
'RUB': '643',
|
||||
'SAR': '682',
|
||||
'SGD': '702',
|
||||
'ZAR': '710',
|
||||
'SEK': '752',
|
||||
'CHF': '756',
|
||||
'THB': '764',
|
||||
'AED': '784',
|
||||
'TND': '788',
|
||||
'GBP': '826',
|
||||
'USD': '840',
|
||||
'TWD': '901',
|
||||
'RSD': '941',
|
||||
'RON': '946',
|
||||
'TRY': '949',
|
||||
'XOF': '952',
|
||||
'XPF': '953',
|
||||
'BGN': '975',
|
||||
'EUR': '978',
|
||||
'UAH': '980',
|
||||
'PLN': '996',
|
||||
'BRL': '986',
|
||||
}
|
||||
|
||||
# Mapping of transaction states to Sips response codes.
|
||||
# See https://documentation.sips.worldline.com/en/WLSIPS.001-GD-Data-dictionary.html#Sips.001_DD_en-Value-currencyCode
|
||||
RESPONSE_CODES_MAPPING = {
|
||||
'pending': ('60',),
|
||||
'done': ('00',),
|
||||
'cancel': (
|
||||
'03', '05', '12', '14', '17', '24', '25', '30', '34', '40', '51', '54', '63', '75', '90',
|
||||
'94', '97', '99'
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
# Copyright 2015 Eezee-It
|
||||
|
||||
import datetime
|
||||
from dateutil import parser
|
||||
import json
|
||||
import logging
|
||||
import pytz
|
||||
import re
|
||||
import time
|
||||
from hashlib import sha256
|
||||
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import models, fields, api
|
||||
from odoo.tools.float_utils import float_compare
|
||||
from odoo.tools.translate import _
|
||||
from odoo.addons.payment.models.payment_acquirer import ValidationError
|
||||
from odoo.addons.payment_sips.controllers.main import SipsController
|
||||
|
||||
from .const import SIPS_SUPPORTED_CURRENCIES
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AcquirerSips(models.Model):
|
||||
_inherit = 'payment.acquirer'
|
||||
|
||||
provider = fields.Selection(selection_add=[('sips', 'Sips')], ondelete={'sips': 'set default'})
|
||||
sips_merchant_id = fields.Char('Merchant ID', required_if_provider='sips', groups='base.group_user')
|
||||
sips_secret = fields.Char('Secret Key', size=64, required_if_provider='sips', groups='base.group_user')
|
||||
sips_test_url = fields.Char("Test url", required_if_provider='sips', default='https://payment-webinit.simu.sips-atos.com/paymentInit')
|
||||
sips_prod_url = fields.Char("Production url", required_if_provider='sips', default='https://payment-webinit.sips-atos.com/paymentInit')
|
||||
sips_version = fields.Char("Interface Version", required_if_provider='sips', default='HP_2.31')
|
||||
sips_key_version = fields.Integer("Secret Key Version", required_if_provider='sips', default=2)
|
||||
|
||||
def _sips_generate_shasign(self, values):
|
||||
""" Generate the shasign for incoming or outgoing communications.
|
||||
:param dict values: transaction values
|
||||
:return string: shasign
|
||||
"""
|
||||
if self.provider != 'sips':
|
||||
raise ValidationError(_('Incorrect payment acquirer provider'))
|
||||
data = values['Data']
|
||||
key = self.sips_secret
|
||||
|
||||
shasign = sha256((data + key).encode('utf-8'))
|
||||
return shasign.hexdigest()
|
||||
|
||||
def sips_form_generate_values(self, values):
|
||||
self.ensure_one()
|
||||
base_url = self.get_base_url()
|
||||
currency = self.env['res.currency'].sudo().browse(values['currency_id'])
|
||||
sips_currency = SIPS_SUPPORTED_CURRENCIES.get(currency.name)
|
||||
if not sips_currency:
|
||||
raise ValidationError(_('Currency not supported by Wordline: %s') % currency.name)
|
||||
# rounded to its smallest unit, depends on the currency
|
||||
amount = round(values['amount'] * (10 ** sips_currency.decimal))
|
||||
|
||||
sips_tx_values = dict(values)
|
||||
data = {
|
||||
'amount': amount,
|
||||
'currencyCode': sips_currency.iso_id,
|
||||
'merchantId': self.sips_merchant_id,
|
||||
'normalReturnUrl': urls.url_join(base_url, SipsController._return_url),
|
||||
'automaticResponseUrl': urls.url_join(base_url, SipsController._notify_url),
|
||||
'transactionReference': values['reference'],
|
||||
'statementReference': values['reference'],
|
||||
'keyVersion': self.sips_key_version,
|
||||
}
|
||||
sips_tx_values.update({
|
||||
'Data': '|'.join([f'{k}={v}' for k,v in data.items()]),
|
||||
'InterfaceVersion': self.sips_version,
|
||||
})
|
||||
|
||||
return_context = {}
|
||||
if sips_tx_values.get('return_url'):
|
||||
return_context['return_url'] = urls.url_quote(sips_tx_values.get('return_url'))
|
||||
return_context['reference'] = sips_tx_values['reference']
|
||||
sips_tx_values['Data'] += '|returnContext=%s' % (json.dumps(return_context))
|
||||
|
||||
shasign = self._sips_generate_shasign(sips_tx_values)
|
||||
sips_tx_values['Seal'] = shasign
|
||||
return sips_tx_values
|
||||
|
||||
def sips_get_form_action_url(self):
|
||||
self.ensure_one()
|
||||
return self.sips_prod_url if self.state == 'enabled' else self.sips_test_url
|
||||
|
||||
|
||||
class TxSips(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
_sips_valid_tx_status = ['00']
|
||||
_sips_wait_tx_status = ['90', '99']
|
||||
_sips_refused_tx_status = ['05', '14', '34', '54', '75', '97']
|
||||
_sips_error_tx_status = ['03', '12', '24', '25', '30', '40', '51', '63', '94']
|
||||
_sips_pending_tx_status = ['60']
|
||||
_sips_cancel_tx_status = ['17']
|
||||
|
||||
@api.model
|
||||
def _compute_reference(self, values=None, prefix=None):
|
||||
res = super()._compute_reference(values=values, prefix=prefix)
|
||||
acquirer = self.env['payment.acquirer'].browse(values.get('acquirer_id'))
|
||||
if acquirer and acquirer.provider == 'sips':
|
||||
return re.sub(r'[^0-9a-zA-Z]+', 'x', res) + 'x' + str(int(time.time()))
|
||||
return res
|
||||
|
||||
# --------------------------------------------------
|
||||
# FORM RELATED METHODS
|
||||
# --------------------------------------------------
|
||||
|
||||
def _sips_data_to_object(self, data):
|
||||
res = {}
|
||||
for element in data.split('|'):
|
||||
(key, value) = element.split('=')
|
||||
res[key] = value
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _sips_form_get_tx_from_data(self, data):
|
||||
""" Given a data dict coming from sips, verify it and find the related
|
||||
transaction record. """
|
||||
|
||||
data = self._sips_data_to_object(data.get('Data'))
|
||||
reference = data.get('transactionReference')
|
||||
|
||||
if not reference:
|
||||
return_context = json.loads(data.get('returnContext', '{}'))
|
||||
reference = return_context.get('reference')
|
||||
|
||||
payment_tx = self.search([('reference', '=', reference)])
|
||||
if not payment_tx:
|
||||
error_msg = _('Sips: received data for reference %s; no order found') % reference
|
||||
_logger.error(error_msg)
|
||||
raise ValidationError(error_msg)
|
||||
return payment_tx
|
||||
|
||||
def _sips_form_get_invalid_parameters(self, data):
|
||||
invalid_parameters = []
|
||||
|
||||
data = self._sips_data_to_object(data.get('Data'))
|
||||
|
||||
# amounts should match
|
||||
# get currency decimals from const
|
||||
sips_currency = SIPS_SUPPORTED_CURRENCIES.get(self.currency_id.name)
|
||||
# convert from int to float using decimals from currency
|
||||
amount_converted = float(data.get('amount', '0.0')) / (10 ** sips_currency.decimal)
|
||||
if float_compare(amount_converted, self.amount, sips_currency.decimal) != 0:
|
||||
invalid_parameters.append(('amount', data.get('amount'), '%.2f' % self.amount))
|
||||
|
||||
return invalid_parameters
|
||||
|
||||
def _sips_form_validate(self, data):
|
||||
data = self._sips_data_to_object(data.get('Data'))
|
||||
status = data.get('responseCode')
|
||||
date = data.get('transactionDateTime')
|
||||
if date:
|
||||
try:
|
||||
# dateutil.parser 2.5.3 and up should handle dates formatted as
|
||||
# '2020-04-08T05:54:18+02:00', which strptime does not
|
||||
# (+02:00 does not work as %z expects +0200 before Python 3.7)
|
||||
# See odoo/odoo#49160
|
||||
date = parser.parse(date).astimezone(pytz.utc).replace(tzinfo=None)
|
||||
except:
|
||||
# fallback on now to avoid failing to register the payment
|
||||
# because a provider formats their dates badly or because
|
||||
# some library is not behaving
|
||||
date = fields.Datetime.now()
|
||||
data = {
|
||||
'acquirer_reference': data.get('transactionReference'),
|
||||
'date': date,
|
||||
}
|
||||
res = False
|
||||
if status in self._sips_valid_tx_status:
|
||||
msg = f'ref: {self.reference}, got valid response [{status}], set as done.'
|
||||
_logger.info(msg)
|
||||
data.update(state_message=msg)
|
||||
self.write(data)
|
||||
self._set_transaction_done()
|
||||
res = True
|
||||
elif status in self._sips_error_tx_status:
|
||||
msg = f'ref: {self.reference}, got response [{status}], set as cancel.'
|
||||
data.update(state_message=msg)
|
||||
self.write(data)
|
||||
self._set_transaction_cancel()
|
||||
elif status in self._sips_wait_tx_status:
|
||||
msg = f'ref: {self.reference}, got wait response [{status}], set as cancel.'
|
||||
data.update(state_message=msg)
|
||||
self.write(data)
|
||||
self._set_transaction_cancel()
|
||||
elif status in self._sips_refused_tx_status:
|
||||
msg = f'ref: {self.reference}, got refused response [{status}], set as cancel.'
|
||||
data.update(state_message=msg)
|
||||
self.write(data)
|
||||
self._set_transaction_cancel()
|
||||
elif status in self._sips_pending_tx_status:
|
||||
msg = f'ref: {self.reference}, got pending response [{status}], set as pending.'
|
||||
data.update(state_message=msg)
|
||||
self.write(data)
|
||||
self._set_transaction_pending()
|
||||
elif status in self._sips_cancel_tx_status:
|
||||
msg = f'ref: {self.reference}, got cancel response [{status}], set as cancel.'
|
||||
data.update(state_message=msg)
|
||||
self.write(data)
|
||||
self._set_transaction_cancel()
|
||||
else:
|
||||
msg = f'ref: {self.reference}, got unrecognized response [{status}], set as cancel.'
|
||||
data.update(state_message=msg)
|
||||
self.write(data)
|
||||
self._set_transaction_cancel()
|
||||
|
||||
_logger.info(msg)
|
||||
return res
|
||||
@@ -0,0 +1,56 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
# Original Copyright 2015 Eezee-It, modified and maintained by Odoo.
|
||||
|
||||
from hashlib import sha256
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
from .const import SUPPORTED_CURRENCIES
|
||||
|
||||
|
||||
class PaymentAcquirer(models.Model):
|
||||
_inherit = 'payment.acquirer'
|
||||
|
||||
provider = fields.Selection(
|
||||
selection_add=[('sips', "Sips")], ondelete={'sips': 'set default'})
|
||||
sips_merchant_id = fields.Char(
|
||||
string="Merchant ID", help="The ID solely used to identify the merchant account with Sips",
|
||||
required_if_provider='sips')
|
||||
sips_secret = fields.Char(
|
||||
string="SIPS Secret Key", size=64, required_if_provider='sips', groups='base.group_system')
|
||||
sips_key_version = fields.Integer(
|
||||
string="Secret Key Version", required_if_provider='sips', default=2)
|
||||
sips_test_url = fields.Char(
|
||||
string="Test URL", required_if_provider='sips',
|
||||
default="https://payment-webinit.simu.sips-atos.com/paymentInit")
|
||||
sips_prod_url = fields.Char(
|
||||
string="Production URL", required_if_provider='sips',
|
||||
default="https://payment-webinit.sips-atos.com/paymentInit")
|
||||
sips_version = fields.Char(
|
||||
string="Interface Version", required_if_provider='sips', default="HP_2.31")
|
||||
|
||||
@api.model
|
||||
def _get_compatible_acquirers(self, *args, currency_id=None, **kwargs):
|
||||
""" Override of payment to unlist Sips acquirers when the currency is not supported. """
|
||||
acquirers = super()._get_compatible_acquirers(*args, currency_id=currency_id, **kwargs)
|
||||
|
||||
currency = self.env['res.currency'].browse(currency_id).exists()
|
||||
if currency and currency.name not in SUPPORTED_CURRENCIES:
|
||||
acquirers = acquirers.filtered(lambda a: a.provider != 'sips')
|
||||
|
||||
return acquirers
|
||||
|
||||
def _sips_generate_shasign(self, data):
|
||||
""" Generate the shasign for incoming or outgoing communications.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param str data: The data to use to generate the shasign
|
||||
:return: shasign
|
||||
:rtype: str
|
||||
"""
|
||||
self.ensure_one()
|
||||
|
||||
key = self.sips_secret
|
||||
shasign = sha256((data + key).encode('utf-8'))
|
||||
return shasign.hexdigest()
|
||||
@@ -0,0 +1,168 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
# Original Copyright 2015 Eezee-It, modified and maintained by Odoo.
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from werkzeug import urls
|
||||
|
||||
from odoo import _, api, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from odoo.addons.payment import utils as payment_utils
|
||||
from odoo.addons.payment_sips.controllers.main import SipsController
|
||||
from .const import RESPONSE_CODES_MAPPING, SUPPORTED_CURRENCIES
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PaymentTransaction(models.Model):
|
||||
_inherit = 'payment.transaction'
|
||||
|
||||
@api.model
|
||||
def _compute_reference(self, provider, prefix=None, separator='-', **kwargs):
|
||||
""" Override of payment to ensure that Sips requirements for references are satisfied.
|
||||
|
||||
Sips requirements for transaction are as follows:
|
||||
- References can only be made of alphanumeric characters.
|
||||
This is satisfied by forcing the custom separator to 'x' to ensure that no '-' character
|
||||
will be used to append a suffix. Additionally, the prefix is sanitized if it was provided,
|
||||
and generated with 'tx' as default otherwise. This prevents the prefix to be generated
|
||||
based on document names that may contain non-alphanum characters (eg: INV/2020/...).
|
||||
- References must be unique at provider level for a given merchant account.
|
||||
This is satisfied by singularizing the prefix with the current datetime. If two
|
||||
transactions are created simultaneously, `_compute_reference` ensures the uniqueness of
|
||||
references by suffixing a sequence number.
|
||||
|
||||
:param str provider: The provider of the acquirer handling the transaction
|
||||
:param str prefix: The custom prefix used to compute the full reference
|
||||
:param str separator: The custom separator used to separate the prefix from the suffix
|
||||
:return: The unique reference for the transaction
|
||||
:rtype: str
|
||||
"""
|
||||
if provider == 'sips':
|
||||
# We use an empty separator for cosmetic reasons: As the default prefix is 'tx', we want
|
||||
# the singularized prefix to look like 'tx2020...' and not 'txx2020...'.
|
||||
prefix = payment_utils.singularize_reference_prefix(separator='')
|
||||
separator = 'x' # Still, we need a dedicated separator between the prefix and the seq.
|
||||
return super()._compute_reference(provider, prefix=prefix, separator=separator, **kwargs)
|
||||
|
||||
def _get_specific_rendering_values(self, processing_values):
|
||||
""" Override of payment to return Sips-specific rendering values.
|
||||
|
||||
Note: self.ensure_one() from `_get_processing_values`
|
||||
|
||||
:param dict processing_values: The generic and specific processing values of the transaction
|
||||
:return: The dict of acquirer-specific processing values
|
||||
:rtype: dict
|
||||
"""
|
||||
res = super()._get_specific_rendering_values(processing_values)
|
||||
if self.provider != 'sips':
|
||||
return res
|
||||
|
||||
base_url = self.get_base_url()
|
||||
data = {
|
||||
'amount': payment_utils.to_minor_currency_units(self.amount, self.currency_id),
|
||||
'currencyCode': SUPPORTED_CURRENCIES[self.currency_id.name], # The ISO 4217 code
|
||||
'merchantId': self.acquirer_id.sips_merchant_id,
|
||||
'normalReturnUrl': urls.url_join(base_url, SipsController._return_url),
|
||||
'automaticResponseUrl': urls.url_join(base_url, SipsController._notify_url),
|
||||
'transactionReference': self.reference,
|
||||
'statementReference': self.reference,
|
||||
'keyVersion': self.acquirer_id.sips_key_version,
|
||||
'returnContext': json.dumps(dict(reference=self.reference)),
|
||||
}
|
||||
api_url = self.acquirer_id.sips_prod_url if self.acquirer_id.state == 'enabled' \
|
||||
else self.acquirer_id.sips_test_url
|
||||
data = '|'.join([f'{k}={v}' for k, v in data.items()])
|
||||
return {
|
||||
'api_url': api_url,
|
||||
'Data': data,
|
||||
'InterfaceVersion': self.acquirer_id.sips_version,
|
||||
'Seal': self.acquirer_id._sips_generate_shasign(data),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_tx_from_feedback_data(self, provider, data):
|
||||
""" Override of payment to find the transaction based on Sips data.
|
||||
|
||||
:param str provider: The provider of the acquirer that handled the transaction
|
||||
:param dict data: The feedback data sent by the provider
|
||||
:return: The transaction if found
|
||||
:rtype: recordset of `payment.transaction`
|
||||
:raise: ValidationError if the data match no transaction
|
||||
:raise: ValidationError if the currency is not supported
|
||||
:raise: ValidationError if the amount mismatch
|
||||
"""
|
||||
tx = super()._get_tx_from_feedback_data(provider, data)
|
||||
if provider != 'sips':
|
||||
return tx
|
||||
|
||||
data = self._sips_data_to_object(data['Data'])
|
||||
reference = data.get('transactionReference')
|
||||
|
||||
if not reference:
|
||||
return_context = json.loads(data.get('returnContext', '{}'))
|
||||
reference = return_context.get('reference')
|
||||
|
||||
tx = self.search([('reference', '=', reference), ('provider', '=', 'sips')])
|
||||
if not tx:
|
||||
raise ValidationError(
|
||||
"Sips: " + _("No transaction found matching reference %s.", reference)
|
||||
)
|
||||
|
||||
sips_currency = SUPPORTED_CURRENCIES.get(tx.currency_id.name)
|
||||
if not sips_currency:
|
||||
raise ValidationError(
|
||||
"Sips: " + _("This currency is not supported: %s.", tx.currency_id.name)
|
||||
)
|
||||
|
||||
amount_converted = payment_utils.to_major_currency_units(
|
||||
float(data.get('amount', '0.0')), tx.currency_id
|
||||
)
|
||||
if tx.currency_id.compare_amounts(amount_converted, tx.amount) != 0:
|
||||
raise ValidationError(
|
||||
"Sips: " + _(
|
||||
"Incorrect amount: received %(received).2f, expected %(expected).2f",
|
||||
received=amount_converted, expected=tx.amount
|
||||
)
|
||||
)
|
||||
return tx
|
||||
|
||||
def _process_feedback_data(self, data):
|
||||
""" Override of payment to process the transaction based on Sips data.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:param dict data: The feedback data sent by the provider
|
||||
:return: None
|
||||
"""
|
||||
super()._process_feedback_data(data)
|
||||
if self.provider != 'sips':
|
||||
return
|
||||
|
||||
data = self._sips_data_to_object(data.get('Data'))
|
||||
self.acquirer_reference = data.get('transactionReference')
|
||||
response_code = data.get('responseCode')
|
||||
if response_code in RESPONSE_CODES_MAPPING['pending']:
|
||||
status = "pending"
|
||||
self._set_pending()
|
||||
elif response_code in RESPONSE_CODES_MAPPING['done']:
|
||||
status = "done"
|
||||
self._set_done()
|
||||
elif response_code in RESPONSE_CODES_MAPPING['cancel']:
|
||||
status = "cancel"
|
||||
self._set_canceled()
|
||||
else:
|
||||
status = "error"
|
||||
self._set_error(_("Unrecognized response received from the payment provider."))
|
||||
_logger.info(
|
||||
"ref: %s, got response [%s], set as '%s'.", self.reference, response_code, status
|
||||
)
|
||||
|
||||
def _sips_data_to_object(self, data):
|
||||
res = {}
|
||||
for element in data.split('|'):
|
||||
key, value = element.split('=')
|
||||
res[key] = value
|
||||
return res
|
||||
@@ -1,3 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import common
|
||||
from . import test_sips
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
from odoo.addons.payment.tests.common import PaymentCommon
|
||||
|
||||
|
||||
class SipsCommon(PaymentCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
|
||||
cls.sips = cls._prepare_acquirer('sips', update_values={
|
||||
'sips_merchant_id': 'dummy_mid',
|
||||
'sips_secret': 'dummy_secret',
|
||||
})
|
||||
|
||||
# Override default values
|
||||
cls.acquirer = cls.sips
|
||||
cls.currency = cls.currency_euro
|
||||
@@ -1,40 +1,67 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import json
|
||||
|
||||
from freezegun import freeze_time
|
||||
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.payment.tests.common import PaymentAcquirerCommon
|
||||
from odoo.tools import mute_logger
|
||||
|
||||
from .common import SipsCommon
|
||||
from ..controllers.main import SipsController
|
||||
from ..models.payment_acquirer import SUPPORTED_CURRENCIES
|
||||
|
||||
@tagged('post_install', '-at_install', '-standard', 'external')
|
||||
class SipsTest(PaymentAcquirerCommon):
|
||||
@tagged('post_install', '-at_install')
|
||||
class SipsTest(SipsCommon):
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls, chart_template_ref=None):
|
||||
super().setUpClass(chart_template_ref=chart_template_ref)
|
||||
cls.sips = cls.env.ref('payment.payment_acquirer_sips')
|
||||
cls.sips.write({
|
||||
'state': 'test',
|
||||
'sips_merchant_id': 'dummy_mid',
|
||||
'sips_secret': 'dummy_secret',
|
||||
})
|
||||
def test_compatible_acquirers(self):
|
||||
for curr in SUPPORTED_CURRENCIES:
|
||||
currency = self._prepare_currency(curr)
|
||||
acquirers = self.env['payment.acquirer']._get_compatible_acquirers(
|
||||
partner_id=self.partner.id,
|
||||
company_id=self.company.id,
|
||||
currency_id=currency.id,
|
||||
)
|
||||
self.assertIn(self.sips, acquirers)
|
||||
|
||||
def test_10_sips_form_render(self):
|
||||
self.assertEqual(self.sips.state, 'test', 'test without test environment')
|
||||
unsupported_currency = self._prepare_currency('VEF')
|
||||
acquirers = self.env['payment.acquirer']._get_compatible_acquirers(
|
||||
partner_id=self.partner.id,
|
||||
company_id=self.company.id,
|
||||
currency_id=unsupported_currency.id,
|
||||
)
|
||||
self.assertNotIn(self.sips, acquirers)
|
||||
|
||||
# ----------------------------------------
|
||||
# Test: button direct rendering
|
||||
# ----------------------------------------
|
||||
# freeze time for consistent singularize_prefix behavior during the test
|
||||
@freeze_time("2011-11-02 12:00:21")
|
||||
def test_reference(self):
|
||||
tx = self.create_transaction(flow="redirect", reference="")
|
||||
self.assertEqual(tx.reference, "tx20111102120021",
|
||||
"Payulatam: transaction reference wasn't correctly singularized.")
|
||||
|
||||
# render the button
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'acquirer_id': self.sips.id,
|
||||
'amount': 100.0,
|
||||
'reference': 'SO404',
|
||||
'currency_id': self.currency_euro.id,
|
||||
})
|
||||
self.sips.render('SO404', 100.0, self.currency_euro.id, values=self.buyer_values).decode('utf-8')
|
||||
def test_redirect_form_values(self):
|
||||
tx = self.create_transaction(flow="redirect")
|
||||
|
||||
def test_20_sips_form_management(self):
|
||||
self.assertEqual(self.sips.state, 'test', 'test without test environment')
|
||||
with mute_logger('odoo.addons.payment.models.payment_transaction'):
|
||||
processing_values = tx._get_processing_values()
|
||||
form_info = self._extract_values_from_html_form(processing_values['redirect_form_html'])
|
||||
form_inputs = form_info['inputs']
|
||||
|
||||
self.assertEqual(form_info['action'], self.sips.sips_test_url)
|
||||
self.assertEqual(form_inputs['InterfaceVersion'], self.sips.sips_version)
|
||||
return_url = self._build_url(SipsController._return_url)
|
||||
notify_url = self._build_url(SipsController._notify_url)
|
||||
self.assertEqual(form_inputs['Data'],
|
||||
f'amount=111111|currencyCode=978|merchantId=dummy_mid|normalReturnUrl={return_url}|' \
|
||||
f'automaticResponseUrl={notify_url}|transactionReference={self.reference}|' \
|
||||
f'statementReference={self.reference}|keyVersion={self.sips.sips_key_version}|' \
|
||||
f'returnContext={json.dumps(dict(reference=self.reference))}'
|
||||
)
|
||||
self.assertEqual(form_inputs['Seal'],
|
||||
'4d7cc67c0168e8ce11c25fbe1937231c644861e320702ab544022b032b9eb4a2')
|
||||
|
||||
def test_feedback_processing(self):
|
||||
# typical data posted by Sips after client has successfully paid
|
||||
sips_post_data = {
|
||||
'Data': 'captureDay=0|captureMode=AUTHOR_CAPTURE|currencyCode=840|'
|
||||
@@ -56,22 +83,22 @@ class SipsTest(PaymentAcquirerCommon):
|
||||
'Encode': '',
|
||||
'InterfaceVersion': 'HP_2.4',
|
||||
'Seal': 'f03f64da6f57c171904d12bf709b1d6d3385131ac914e97a7e1db075ed438f3e',
|
||||
'locale': 'en'
|
||||
'locale': 'en',
|
||||
}
|
||||
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'amount': 314.0,
|
||||
'acquirer_id': self.sips.id,
|
||||
'currency_id': self.currency_euro.id,
|
||||
'reference': 'SO100x1',
|
||||
'partner_name': 'Norbert Buyer',
|
||||
'partner_country_id': self.country_france.id})
|
||||
with self.assertRaises(ValidationError): # unknown transaction
|
||||
self.env['payment.transaction']._handle_feedback_data('sips', sips_post_data)
|
||||
|
||||
# validate it
|
||||
tx.form_feedback(sips_post_data, 'sips')
|
||||
self.amount = 314.0
|
||||
self.reference = 'SO100x1'
|
||||
|
||||
tx = self.create_transaction(flow="redirect")
|
||||
|
||||
# Validate the transaction
|
||||
self.env['payment.transaction']._handle_feedback_data('sips', sips_post_data)
|
||||
self.assertEqual(tx.state, 'done', 'Sips: validation did not put tx into done state')
|
||||
self.assertEqual(tx.acquirer_reference, 'SO100x1', 'Sips: validation did not update tx id')
|
||||
|
||||
self.assertEqual(tx.acquirer_reference, self.reference, 'Sips: validation did not update tx id')
|
||||
|
||||
# same process for an payment in error on sips's end
|
||||
sips_post_data = {
|
||||
'Data': 'captureDay=0|captureMode=AUTHOR_CAPTURE|currencyCode=840|'
|
||||
@@ -87,53 +114,8 @@ class SipsTest(PaymentAcquirerCommon):
|
||||
'InterfaceVersion': 'HP_2.4',
|
||||
'Seal': '6e1995ea5432580860a04d8515b6eb1507996f97b3c5fa04fb6d9568121a16a2'
|
||||
}
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'amount': 314.0,
|
||||
'acquirer_id': self.sips.id,
|
||||
'currency_id': self.currency_euro.id,
|
||||
'reference': 'SO100x2',
|
||||
'partner_name': 'Norbert Buyer',
|
||||
'partner_country_id': self.country_france.id})
|
||||
tx.form_feedback(sips_post_data, 'sips')
|
||||
# check state
|
||||
self.assertEqual(tx.state, 'cancel', 'Sips: erroneous validation did not put tx into error state')
|
||||
self.reference = 'SO100x2'
|
||||
tx2 = self.create_transaction(flow="redirect")
|
||||
|
||||
def test_30_sips_badly_formatted_date(self):
|
||||
self.assertEqual(self.sips.state, 'test', 'test without test environment')
|
||||
|
||||
# typical data posted by Sips after client has successfully paid
|
||||
bad_date = '2020-04-08T06:15:59+56:00'
|
||||
sips_post_data = {
|
||||
'Data': 'captureDay=0|captureMode=AUTHOR_CAPTURE|currencyCode=840|'
|
||||
'merchantId=002001000000001|orderChannel=INTERNET|'
|
||||
'responseCode=00|transactionDateTime=%s|'
|
||||
'transactionReference=SO100x1|keyVersion=1|'
|
||||
'acquirerResponseCode=00|amount=31400|authorisationId=0020000006791167|'
|
||||
'paymentMeanBrand=IDEAL|paymentMeanType=CREDIT_TRANSFER|'
|
||||
'customerIpAddress=127.0.0.1|returnContext={"return_url": '
|
||||
'"/payment/process", "reference": '
|
||||
'"SO100x1"}|holderAuthentRelegation=N|holderAuthentStatus=|'
|
||||
'transactionOrigin=INTERNET|paymentPattern=ONE_SHOT|customerMobilePhone=null|'
|
||||
'mandateAuthentMethod=null|mandateUsage=null|transactionActors=null|'
|
||||
'mandateId=null|captureLimitDate=20200408|dccStatus=null|dccResponseCode=null|'
|
||||
'dccAmount=null|dccCurrencyCode=null|dccExchangeRate=null|'
|
||||
'dccExchangeRateValidity=null|dccProvider=null|'
|
||||
'statementReference=SO100x1|panEntryMode=MANUAL|walletType=null|'
|
||||
'holderAuthentMethod=NO_AUTHENT_METHOD' % (bad_date,),
|
||||
'Encode': '',
|
||||
'InterfaceVersion': 'HP_2.4',
|
||||
'Seal': 'f03f64da6f57c171904d12bf709b1d6d3385131ac914e97a7e1db075ed438f3e',
|
||||
'locale': 'en'
|
||||
}
|
||||
|
||||
tx = self.env['payment.transaction'].create({
|
||||
'amount': 314.0,
|
||||
'acquirer_id': self.sips.id,
|
||||
'currency_id': self.currency_euro.id,
|
||||
'reference': 'SO100x1',
|
||||
'partner_name': 'Norbert Buyer',
|
||||
'partner_country_id': self.country_france.id})
|
||||
|
||||
# validate it
|
||||
tx.form_feedback(sips_post_data, 'sips')
|
||||
self.assertEqual(tx.state, 'done', 'Sips: validation did not put tx into done state when date format was weird')
|
||||
self.env['payment.transaction']._handle_feedback_data('sips', sips_post_data)
|
||||
self.assertEqual(tx2.state, 'cancel', 'Sips: erroneous validation did not put tx into error state')
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data noupdate="0">
|
||||
<template id="sips_form">
|
||||
<input type="hidden" name="data_set" t-att-data-action-url="tx_url" data-remove-me=""/>
|
||||
|
||||
<template id="redirect_form">
|
||||
<form t-att-action="api_url" method="post">
|
||||
<input type="hidden" name="Data" t-att-value="Data"/>
|
||||
<input type="hidden" name="InterfaceVersion" t-att-value="InterfaceVersion"/>
|
||||
<input type="hidden" name="Seal" t-att-value="Seal"/>
|
||||
</template>
|
||||
</data>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<data>
|
||||
|
||||
<record id="acquirer_form_sips" model="ir.ui.view">
|
||||
<field name="name">acquirer.form.sips</field>
|
||||
<field name="model">payment.acquirer</field>
|
||||
<field name="inherit_id" ref="payment.acquirer_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr='//group[@name="acquirer"]' position='inside'>
|
||||
<group attrs="{'invisible': [('provider', '!=', 'sips')]}">
|
||||
<field name="sips_merchant_id" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="sips_secret" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="sips_test_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/>
|
||||
<field name="sips_prod_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/>
|
||||
<field name="sips_version" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" />
|
||||
<field name="sips_key_version" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" />
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
<record id="payment_acquirer_form" model="ir.ui.view">
|
||||
<field name="name">Sips Acquirer Form</field>
|
||||
<field name="model">payment.acquirer</field>
|
||||
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr='//group[@name="acquirer"]' position='inside'>
|
||||
<group attrs="{'invisible': [('provider', '!=', 'sips')]}">
|
||||
<field name="sips_merchant_id" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="sips_secret" string="Secret Key" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}"/>
|
||||
<field name="sips_key_version" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" />
|
||||
<field name="sips_test_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/>
|
||||
<field name="sips_prod_url" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" groups='base.group_no_one'/>
|
||||
<field name="sips_version" attrs="{'required':[ ('provider', '=', 'sips'), ('state', '!=', 'disabled')]}" />
|
||||
</group>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
</data>
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user