diff --git a/addons/payment_sips/__init__.py b/addons/payment_sips/__init__.py index 0d6cb337a6e..ba88cb2944c 100644 --- a/addons/payment_sips/__init__.py +++ b/addons/payment_sips/__init__.py @@ -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') diff --git a/addons/payment_sips/__manifest__.py b/addons/payment_sips/__manifest__.py index c3afefb2714..c6628c76a1a 100644 --- a/addons/payment_sips/__manifest__.py +++ b/addons/payment_sips/__manifest__.py @@ -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', } diff --git a/addons/payment_sips/controllers/__init__.py b/addons/payment_sips/controllers/__init__.py index 65a8c12013d..80ee4da1c5e 100644 --- a/addons/payment_sips/controllers/__init__.py +++ b/addons/payment_sips/controllers/__init__.py @@ -1,3 +1,3 @@ -# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main diff --git a/addons/payment_sips/controllers/main.py b/addons/payment_sips/controllers/main.py index 1a9f99fa701..c9d53415f1a 100644 --- a/addons/payment_sips/controllers/main.py +++ b/addons/payment_sips/controllers/main.py @@ -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 diff --git a/addons/payment_sips/data/payment_acquirer_data.xml b/addons/payment_sips/data/payment_acquirer_data.xml index f6a489c72cf..043cbece8a3 100644 --- a/addons/payment_sips/data/payment_acquirer_data.xml +++ b/addons/payment_sips/data/payment_acquirer_data.xml @@ -1,14 +1,12 @@ - - + - - Sips - - sips - - - + + sips + + False + False + False + - diff --git a/addons/payment_sips/models/__init__.py b/addons/payment_sips/models/__init__.py index ef12533682f..8e04237423a 100644 --- a/addons/payment_sips/models/__init__.py +++ b/addons/payment_sips/models/__init__.py @@ -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 diff --git a/addons/payment_sips/models/const.py b/addons/payment_sips/models/const.py index 7c6dd9374ee..4b6bd9094fb 100644 --- a/addons/payment_sips/models/const.py +++ b/addons/payment_sips/models/const.py @@ -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' + ), } diff --git a/addons/payment_sips/models/payment.py b/addons/payment_sips/models/payment.py deleted file mode 100644 index 979e002b181..00000000000 --- a/addons/payment_sips/models/payment.py +++ /dev/null @@ -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 diff --git a/addons/payment_sips/models/payment_acquirer.py b/addons/payment_sips/models/payment_acquirer.py new file mode 100644 index 00000000000..b96a2c17446 --- /dev/null +++ b/addons/payment_sips/models/payment_acquirer.py @@ -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() diff --git a/addons/payment_sips/models/payment_transaction.py b/addons/payment_sips/models/payment_transaction.py new file mode 100644 index 00000000000..139c2e7e1bc --- /dev/null +++ b/addons/payment_sips/models/payment_transaction.py @@ -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 diff --git a/addons/payment_sips/tests/__init__.py b/addons/payment_sips/tests/__init__.py index 70427147097..d502f348aa2 100644 --- a/addons/payment_sips/tests/__init__.py +++ b/addons/payment_sips/tests/__init__.py @@ -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 diff --git a/addons/payment_sips/tests/common.py b/addons/payment_sips/tests/common.py new file mode 100644 index 00000000000..e113c2a32aa --- /dev/null +++ b/addons/payment_sips/tests/common.py @@ -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 diff --git a/addons/payment_sips/tests/test_sips.py b/addons/payment_sips/tests/test_sips.py index 3b0bd2279e4..8cb413aa144 100644 --- a/addons/payment_sips/tests/test_sips.py +++ b/addons/payment_sips/tests/test_sips.py @@ -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') diff --git a/addons/payment_sips/views/payment_sips_templates.xml b/addons/payment_sips/views/payment_sips_templates.xml index 477bb964140..7c68506c488 100644 --- a/addons/payment_sips/views/payment_sips_templates.xml +++ b/addons/payment_sips/views/payment_sips_templates.xml @@ -1,11 +1,12 @@ - - + diff --git a/addons/payment_sips/views/payment_views.xml b/addons/payment_sips/views/payment_views.xml index 633da7e527a..3b20af3b7f7 100644 --- a/addons/payment_sips/views/payment_views.xml +++ b/addons/payment_sips/views/payment_views.xml @@ -1,24 +1,22 @@ - - - acquirer.form.sips - payment.acquirer - - - - - - - - - - - - - - + + Sips Acquirer Form + payment.acquirer + + + + + + + + + + + + + + -