diff --git a/addons/payment/data/payment_acquirer_data.xml b/addons/payment/data/payment_acquirer_data.xml index 09fecf7a146..69a926ea093 100644 --- a/addons/payment/data/payment_acquirer_data.xml +++ b/addons/payment/data/payment_acquirer_data.xml @@ -79,6 +79,20 @@ ])]"/> + + Asiapay + Credit Card (powered by Asiapay) + + + + + + Authorize.net Credit Card (powered by Authorize) diff --git a/addons/payment_asiapay/README.md b/addons/payment_asiapay/README.md new file mode 100644 index 00000000000..a6c0cb5544d --- /dev/null +++ b/addons/payment_asiapay/README.md @@ -0,0 +1,37 @@ +# AsiaPay + +## Implementation details + +### Supported features + +- Payment with redirection flow. +- Webhook. +- Several payment methods including credit cards, chinese payment methods such as Alipay, and + [others](https://www.asiapay.com/payment.html#option). + +In addition, AsiaPay also allows to implement manual capture, refunds, express checkout, and +multi-currency processing. + +### API and gateway + +We choose to integrate with the Client Post Through Browser gateway which covers the best our needs, +out of the three that AsiaPay offers as of August 2022. + +The entire API reference and the integration guides can be found on the [Integration Guide] +(https://www.paydollar.com/pdf/op/enpdintguide.pdf). + +The version of the API implemented by this module is v3.67. + +## Merge details + +The first version of the module was specified in task +[2845428](https://www.odoo.com/web#id=2845428&model=project.task) and merged with PR +odoo/odoo#98441 in `saas-15.5`. + +## Testing instructions + +Card Number: `4335900000140045` +Expiry Date: `07/2030` +Name: `testing card` +CVC: `123` +3DS Password: `password` diff --git a/addons/payment_asiapay/__init__.py b/addons/payment_asiapay/__init__.py new file mode 100644 index 00000000000..d98b3099c48 --- /dev/null +++ b/addons/payment_asiapay/__init__.py @@ -0,0 +1,10 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models + +from odoo.addons.payment import reset_payment_acquirer + + +def uninstall_hook(cr, registry): + reset_payment_acquirer(cr, registry, 'asiapay') diff --git a/addons/payment_asiapay/__manifest__.py b/addons/payment_asiapay/__manifest__.py new file mode 100644 index 00000000000..48246e595ec --- /dev/null +++ b/addons/payment_asiapay/__manifest__.py @@ -0,0 +1,20 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "Payment Acquirer: AsiaPay", + 'version': '1.0', + 'category': 'Accounting/Payment Acquirers', + 'sequence': 350, + 'summary': "An online payments provider based in Hong Kong covering most Asian countries and " + "many different payment methods.", + 'depends': ['payment'], + 'data': [ + 'views/payment_views.xml', + 'views/payment_asiapay_templates.xml', + + 'data/payment_acquirer_data.xml', + ], + 'application': True, + 'uninstall_hook': 'uninstall_hook', + 'license': 'LGPL-3', +} diff --git a/addons/payment_asiapay/const.py b/addons/payment_asiapay/const.py new file mode 100644 index 00000000000..eb2948846cb --- /dev/null +++ b/addons/payment_asiapay/const.py @@ -0,0 +1,71 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# Mapping of currency ISO 4217 codes AsiaPay's currency codes. +# See https://www.paydollar.com/pdf/op/enpdintguide.pdf for the list of currency codes. +CURRENCY_MAPPING = { + 'AED': '784', + 'AUD': '036', + 'BND': '096', + 'CAD': '124', + 'CNY': '156', + 'EUR': '978', + 'GBP': '826', + 'HKD': '344', + 'IDR': '360', + 'INR': '356', + 'JPY': '392', + 'KRW': '410', + 'MOP': '446', + 'MYR': '458', + 'NZD': '554', + 'PHP': '608', + 'SAR': '682', + 'SGD': '702', + 'THB': '764', + 'TWD': '901', + 'USD': '702', + 'VND': '704', +} + +# The keys of the values to use in the calculation of the signature. +SIGNATURE_KEYS = { + 'outgoing': [ + 'merchant_id', + 'reference', + 'currency_code', + 'amount', + 'payment_type', + ], + 'incoming': [ + 'src', + 'prc', + 'successcode', + 'Ref', + 'PayRef', + 'Cur', + 'Amt', + 'payerAuth', + ], +} + +# Mapping of both country codes (e.g., 'es') and IETF language tags (e.g.: 'fr-BE') to AsiaPay +# language codes. If a language tag is not listed, the country code prefix can serve as fallback. +LANGUAGE_CODES_MAPPING = { + 'en': 'E', + 'zh_HK': 'C', + 'zh_TW': 'C', + 'zh_CN': 'X', + 'ja_JP': 'J', + 'th_TH': 'T', + 'fr': 'F', + 'de': 'G', + 'ru_RU': 'R', + 'es': 'S', + 'vi_VN': 'S', +} + +# Mapping of transaction states to AsiaPay success codes. +SUCCESS_CODE_MAPPING = { + 'done': ('0',), + 'error': ('1',), +} diff --git a/addons/payment_asiapay/controllers/__init__.py b/addons/payment_asiapay/controllers/__init__.py new file mode 100644 index 00000000000..80ee4da1c5e --- /dev/null +++ b/addons/payment_asiapay/controllers/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import main diff --git a/addons/payment_asiapay/controllers/main.py b/addons/payment_asiapay/controllers/main.py new file mode 100644 index 00000000000..2df8ced2504 --- /dev/null +++ b/addons/payment_asiapay/controllers/main.py @@ -0,0 +1,75 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hmac +import logging +import pprint + +from werkzeug.exceptions import Forbidden + +from odoo import http +from odoo.exceptions import ValidationError +from odoo.http import request + + +_logger = logging.getLogger(__name__) + + +class AsiaPayController(http.Controller): + _return_url = '/payment/asiapay/return' + _webhook_url = '/payment/asiapay/webhook' + + @http.route(_return_url, type='http', auth='public', methods=['GET']) + def asiapay_return_from_checkout(self, **data): + """ Process the notification data sent by AsiaPay after redirection. + + :param dict data: The notification data. + """ + # Don't process the notification data as they contain no valuable information except for the + # reference and AsiaPay doesn't expose an endpoint to fetch the data from the API. + return request.redirect('/payment/status') + + @http.route(_webhook_url, type='http', auth='public', methods=['POST'], csrf=False) + def asiapay_webhook(self, **data): + """ Process the notification data sent by AsiaPay to the webhook. + + :param dict data: The notification data. + :return: The 'OK' string to acknowledge the notification. + :rtype: str + """ + _logger.info("Notification received from AsiaPay with data:\n%s", pprint.pformat(data)) + try: + # Check the integrity of the notification data. + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data( + 'asiapay', data + ) + self._verify_notification_signature(data, tx_sudo) + + # Handle the notification data. + tx_sudo._handle_notification_data('asiapay', data) + except ValidationError: # Acknowledge the notification to avoid getting spammed. + _logger.exception("Unable to handle the notification data; skipping to acknowledge.") + + return 'OK' # Acknowledge the notification. + + @staticmethod + def _verify_notification_signature(notification_data, tx_sudo): + """ Check that the received signature matches the expected one. + + :param dict notification_data: The notification data + :param recordset tx_sudo: The sudoed transaction referenced by the notification data, as a + `payment.transaction` record + :return: None + :raise: :class:`werkzeug.exceptions.Forbidden` if the signatures don't match + """ + received_signature = notification_data.get('secureHash') + if not received_signature: + _logger.warning("Received notification with missing signature.") + raise Forbidden() + + # Compare the received signature with the expected signature computed from the data. + expected_signature = tx_sudo.acquirer_id._asiapay_calculate_signature( + notification_data, incoming=True + ) + if not hmac.compare_digest(received_signature, expected_signature): + _logger.warning("Received notification with invalid signature.") + raise Forbidden() diff --git a/addons/payment_asiapay/data/payment_acquirer_data.xml b/addons/payment_asiapay/data/payment_acquirer_data.xml new file mode 100644 index 00000000000..07d9c2da913 --- /dev/null +++ b/addons/payment_asiapay/data/payment_acquirer_data.xml @@ -0,0 +1,15 @@ + + + + + asiapay + + + + + AsiaPay + asiapay + inbound + + + diff --git a/addons/payment_asiapay/models/__init__.py b/addons/payment_asiapay/models/__init__.py new file mode 100644 index 00000000000..28c8703fcc1 --- /dev/null +++ b/addons/payment_asiapay/models/__init__.py @@ -0,0 +1,5 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_payment_method +from . import payment_acquirer +from . import payment_transaction diff --git a/addons/payment_asiapay/models/account_payment_method.py b/addons/payment_asiapay/models/account_payment_method.py new file mode 100644 index 00000000000..d13f5f059f6 --- /dev/null +++ b/addons/payment_asiapay/models/account_payment_method.py @@ -0,0 +1,13 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models + + +class AccountPaymentMethod(models.Model): + _inherit = 'account.payment.method' + + @api.model + def _get_payment_method_information(self): + res = super()._get_payment_method_information() + res['asiapay'] = {'mode': 'unique', 'domain': [('type', '=', 'bank')]} + return res diff --git a/addons/payment_asiapay/models/payment_acquirer.py b/addons/payment_asiapay/models/payment_acquirer.py new file mode 100644 index 00000000000..ef172577ec1 --- /dev/null +++ b/addons/payment_asiapay/models/payment_acquirer.py @@ -0,0 +1,102 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from hashlib import new as hashnew + +from odoo import api, fields, models + +from odoo.addons.payment_asiapay import const + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + def _domain_asiapay_currency_id(self): + currency_xmlids = [f'base.{key}' for key in const.CURRENCY_MAPPING] + return [('id', 'in', [self.env.ref(xmlid).id for xmlid in currency_xmlids])] + + provider = fields.Selection( + selection_add=[('asiapay', "AsiaPay")], ondelete={'asiapay': 'set default'} + ) + asiapay_merchant_id = fields.Char( + string="AsiaPay Merchant ID", + help="The Merchant ID solely used to identify your AsiaPay account.", + required_if_provider='asiapay', + ) + asiapay_currency_id = fields.Many2one( + string="AsiaPay Currency", + help="The currency associated to your AsiaPay account.", + comodel_name='res.currency', + domain=_domain_asiapay_currency_id, + required_if_provider='asiapay', + ) + asiapay_secure_hash_secret = fields.Char( + string="AsiaPay Secure Hash Secret", + required_if_provider='asiapay', + groups='base.group_system', + ) + asiapay_secure_hash_function = fields.Selection( + string="AsiaPay Secure Hash Function", + help="The secure hash function associated to your AsiaPay account.", + selection=[('sha1', "SHA1"), ('sha256', "SHA256"), ('sha512', 'SHA512')], + default='sha1', + required_if_provider='asiapay', + ) + + # === BUSINESS METHODS ===# + + @api.model + def _get_compatible_acquirers(self, *args, currency_id=None, **kwargs): + """ Override of `payment` to filter out AsiaPay acquirers for unsupported currencies. """ + acquirers = super()._get_compatible_acquirers(*args, currency_id=currency_id, **kwargs) + + currency = self.env['res.currency'].browse(currency_id).exists() + if currency: + acquirers = acquirers.filtered( + lambda a: a.provider != 'asiapay' or currency == a.asiapay_currency_id + ) + + return acquirers + + def _asiapay_get_api_url(self): + """ Return the URL of the API corresponding to the acquirer's state. + + :return: The API URL. + :rtype: str + """ + self.ensure_one() + + if self.state == 'enabled': + return 'https://www.paydollar.com/b2c2/eng/payment/payForm.jsp' + else: # 'test' + return 'https://test.paydollar.com/b2cDemo/eng/payment/payForm.jsp' + + def _asiapay_calculate_signature(self, data, incoming=True): + """ Compute the signature for the provided data according to the AsiaPay documentation. + + :param dict data: The data to sign. + :param bool incoming: Whether the signature must be generated for an incoming (AsiaPay to + Odoo) or outgoing (Odoo to AsiaPay) communication. + :return: The calculated signature. + :rtype: str + """ + signature_keys = const.SIGNATURE_KEYS['incoming' if incoming else 'outgoing'] + data_to_sign = [str(data[k]) for k in signature_keys] + [self.asiapay_secure_hash_secret] + signing_string = '|'.join(data_to_sign) + shasign = hashnew(self.asiapay_secure_hash_function) + shasign.update(signing_string.encode()) + return shasign.hexdigest() + + def _get_default_payment_method_id(self): + self.ensure_one() + if self.provider != 'asiapay': + return super()._get_default_payment_method_id() + return self.env.ref('payment_asiapay.payment_method_asiapay').id + + def _neutralize(self): + super()._neutralize() + self._neutralize_fields('asiapay', [ + 'asiapay_merchant_id', + 'asiapay_currency_id', + 'asiapay_secure_hash_secret', + 'asiapay_secure_hash_function', + ]) diff --git a/addons/payment_asiapay/models/payment_transaction.py b/addons/payment_asiapay/models/payment_transaction.py new file mode 100644 index 00000000000..9fda9db7bb2 --- /dev/null +++ b/addons/payment_asiapay/models/payment_transaction.py @@ -0,0 +1,166 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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_asiapay import const +from odoo.addons.payment_asiapay.controllers.main import AsiaPayController + + +_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 AsiaPay requirements for references are satisfied. + + AsiaPay requirements for references are as follows: + - 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. + - References must be at most 35 characters long. + + :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 != 'asiapay': + return super()._compute_reference(provider, prefix=prefix, **kwargs) + + if not prefix: + # If no prefix is provided, it could mean that a module has passed a kwarg intended for + # the `_compute_reference_prefix` method, as it is only called if the prefix is empty. + # We call it manually here because singularizing the prefix would generate a default + # value if it was empty, hence preventing the method from ever being called and the + # transaction from received a reference named after the related document. + prefix = self.sudo()._compute_reference_prefix(provider, separator, **kwargs) or None + prefix = payment_utils.singularize_reference_prefix(prefix=prefix, max_length=35) + return super()._compute_reference(provider, prefix=prefix, **kwargs) + + def _get_specific_rendering_values(self, processing_values): + """ Override of `payment` to return AsiaPay-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 + """ + def get_language_code(lang_): + """ Return the language code corresponding to the provided lang. + + If the lang is not mapped to any language code, the country code is used instead. In + case the country code has no match either, we fall back to English. + + :param str lang_: The lang, in IETF language tag format. + :return: The corresponding language code. + :rtype: str + """ + language_code_ = const.LANGUAGE_CODES_MAPPING.get(lang_) + if not language_code_: + country_code_ = lang_.split('_')[0] + language_code_ = const.LANGUAGE_CODES_MAPPING.get(country_code_) + if not language_code_: + language_code_ = const.LANGUAGE_CODES_MAPPING['en'] + return language_code_ + + res = super()._get_specific_rendering_values(processing_values) + if self.provider != 'asiapay': + return res + + base_url = self.acquirer_id.get_base_url() + # The lang is taken from the context rather than from the partner because it is not required + # to be logged in to make a payment, and because the lang is not always set on the partner. + lang = self._context.get('lang') or 'en_US' + rendering_values = { + 'merchant_id': self.acquirer_id.asiapay_merchant_id, + 'amount': self.amount, + 'reference': self.reference, + 'currency_code': const.CURRENCY_MAPPING[self.acquirer_id.asiapay_currency_id.name], + 'mps_mode': 'SCP', + 'return_url': urls.url_join(base_url, AsiaPayController._return_url), + 'payment_type': 'N', + 'language': get_language_code(lang), + 'payment_method': 'ALL', + } + rendering_values.update({ + 'secure_hash': self.acquirer_id._asiapay_calculate_signature( + rendering_values, incoming=False + ), + 'api_url': self.acquirer_id._asiapay_get_api_url() + }) + return rendering_values + + def _get_tx_from_notification_data(self, provider, notification_data): + """ Override of `payment` to find the transaction based on AsiaPay data. + + :param str provider: The provider of the acquirer that handled the transaction. + :param dict notification_data: The notification data sent by the provider. + :return: The transaction if found. + :rtype: recordset of `payment.transaction` + :raise ValidationError: If inconsistent data are received. + :raise ValidationError: If the data match no transaction. + """ + tx = super()._get_tx_from_notification_data(provider, notification_data) + if provider != 'asiapay' or len(tx) == 1: + return tx + + reference = notification_data.get('Ref') + if not reference: + raise ValidationError( + "AsiaPay: " + _("Received data with missing reference %(ref)s.", ref=reference) + ) + + tx = self.search([('reference', '=', reference), ('provider', '=', 'asiapay')]) + if not tx: + raise ValidationError( + "AsiaPay: " + _("No transaction found matching reference %s.", reference) + ) + + return tx + + def _process_notification_data(self, notification_data): + """ Override of `payment' to process the transaction based on AsiaPay data. + + Note: self.ensure_one() + + :param dict notification_data: The notification data sent by the provider. + :return: None + :raise ValidationError: If inconsistent data are received. + """ + super()._process_notification_data(notification_data) + if self.provider != 'asiapay': + return + + self.acquirer_reference = notification_data.get('PayRef') + + success_code = notification_data.get('successcode') + primary_response_code = notification_data.get('prc') + if not success_code: + raise ValidationError("AsiaPay: " + _("Received data with missing success code.")) + + if success_code in const.SUCCESS_CODE_MAPPING['done']: + self._set_done() + elif success_code in const.SUCCESS_CODE_MAPPING['error']: + self._set_error(_( + "An error occurred during the processing of your payment (success code %s; primary " + "response code %s). Please try again.", success_code, primary_response_code + )) + else: + _logger.warning( + "Received data with invalid success code (%s) for transaction with primary response " + "code %s and reference %s.", success_code, primary_response_code, self.reference + ) + self._set_error("AsiaPay: " + _("Unknown success code: %s", success_code)) diff --git a/addons/payment_asiapay/static/description/icon.png b/addons/payment_asiapay/static/description/icon.png new file mode 100644 index 00000000000..36f40b9aab2 Binary files /dev/null and b/addons/payment_asiapay/static/description/icon.png differ diff --git a/addons/payment_asiapay/static/description/icon.svg b/addons/payment_asiapay/static/description/icon.svg new file mode 100644 index 00000000000..f0af4b2f239 --- /dev/null +++ b/addons/payment_asiapay/static/description/icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/payment_asiapay/tests/__init__.py b/addons/payment_asiapay/tests/__init__.py new file mode 100644 index 00000000000..bad6afe5061 --- /dev/null +++ b/addons/payment_asiapay/tests/__init__.py @@ -0,0 +1,6 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_payment_acquirer +from . import test_payment_transaction +from . import test_processing_flows diff --git a/addons/payment_asiapay/tests/common.py b/addons/payment_asiapay/tests/common.py new file mode 100644 index 00000000000..4a92d2a05e0 --- /dev/null +++ b/addons/payment_asiapay/tests/common.py @@ -0,0 +1,34 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.payment.tests.common import PaymentCommon + + +class AsiaPayCommon(PaymentCommon): + + @classmethod + def setUpClass(cls, chart_template_ref=None): + super().setUpClass(chart_template_ref=chart_template_ref) + + cls.asiapay = cls._prepare_acquirer('asiapay', update_values={ + 'asiapay_merchant_id': '123456789', + 'asiapay_currency_id': cls.currency_euro, + 'asiapay_secure_hash_secret': 'coincoin_motherducker', + 'asiapay_secure_hash_function': 'sha1', + }) + + cls.acquirer = cls.asiapay + + cls.redirect_notification_data = { + 'Ref': cls.reference, + } + cls.webhook_notification_data = { + 'src': 'dummy', + 'prc': 'dummy', + 'successcode': '0', + 'Ref': cls.reference, + 'PayRef': 'dummy', + 'Cur': cls.currency.name, + 'Amt': cls.amount, + 'payerAuth': 'dummy', + 'secureHash': '3e5bf55d9a23969130a6686db7aa4f0230956d0a', + } diff --git a/addons/payment_asiapay/tests/test_payment_acquirer.py b/addons/payment_asiapay/tests/test_payment_acquirer.py new file mode 100644 index 00000000000..d162a7c5443 --- /dev/null +++ b/addons/payment_asiapay/tests/test_payment_acquirer.py @@ -0,0 +1,47 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged + +from odoo.addons.payment_asiapay import const +from odoo.addons.payment_asiapay.tests.common import AsiaPayCommon + + +@tagged('post_install', '-at_install') +class TestPaymentAcquirer(AsiaPayCommon): + + def test_incompatible_with_unsupported_currencies(self): + """ Test that AsiaPay acquirers are filtered out from compatible acquirers when the currency + is not supported. """ + compatible_acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + self.company_id, self.partner.id, self.amount, currency_id=self.env.ref('base.AFN').id + ) + self.assertNotIn(self.asiapay, compatible_acquirers) + + def test_signature_calculation_for_outgoing_data(self): + """ Test that the calculated signature matches the expected signature for outgoing data. """ + calculated_signature = self.asiapay._asiapay_calculate_signature( + { + 'merchant_id': self.asiapay.asiapay_merchant_id, + 'amount': self.amount, + 'reference': self.reference, + 'currency_code': const.CURRENCY_MAPPING[self.currency.name], + 'payment_type': 'N', + }, + incoming=False + ) + self.assertEqual(calculated_signature, '41667af8f428b5a55f44e14e5ab942f57da1ea31') + + def test_signature_calculation_for_incoming_data(self): + """ Test that the calculated signature matches the expected signature for incoming data. """ + calculated_signature = self.asiapay._asiapay_calculate_signature( + self.webhook_notification_data, incoming=True + ) + self.assertEqual(calculated_signature, '3e5bf55d9a23969130a6686db7aa4f0230956d0a') + + def test_neutralize(self): + """ Test that the sensitive fields of the acquirer are correctly neutralized. """ + self.env['payment.acquirer']._neutralize() + self.assertFalse(self.acquirer.asiapay_merchant_id) + self.assertFalse(self.acquirer.asiapay_currency_id) + self.assertFalse(self.acquirer.asiapay_secure_hash_secret) + self.assertFalse(self.acquirer.asiapay_secure_hash_function) diff --git a/addons/payment_asiapay/tests/test_payment_transaction.py b/addons/payment_asiapay/tests/test_payment_transaction.py new file mode 100644 index 00000000000..555e9841565 --- /dev/null +++ b/addons/payment_asiapay/tests/test_payment_transaction.py @@ -0,0 +1,97 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch + +from freezegun import freeze_time + +from odoo.fields import Command +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment.tests.http_common import PaymentHttpCommon +from odoo.addons.payment_asiapay import const +from odoo.addons.payment_asiapay.tests.common import AsiaPayCommon + + +@tagged('post_install', '-at_install') +class TestPaymentTransaction(AsiaPayCommon, PaymentHttpCommon): + + @freeze_time('2011-11-02 12:00:21') # Freeze time for consistent singularization behavior. + def test_reference_is_singularized(self): + """ Test the singularization of reference prefixes. """ + reference = self.env['payment.transaction']._compute_reference(self.asiapay.provider) + self.assertEqual(reference, 'tx-20111102120021') + + @freeze_time('2011-11-02 12:00:21') # Freeze time for consistent singularization behavior. + def test_reference_is_computed_based_on_document_name(self): + """ Test the computation of reference prefixes based on the provided invoice. """ + invoice = self.env['account.move'].create({}) + reference = self.env['payment.transaction']._compute_reference( + self.asiapay.provider, invoice_ids=[Command.set([invoice.id])] + ) + self.assertEqual(reference, 'MISC/2011/11/0001-20111102120021') + + @freeze_time('2011-11-02 12:00:21') # Freeze time for consistent singularization behavior. + def test_reference_is_stripped_at_max_length(self): + """ Test that reference prefixes are stripped to have a length of at most 35 chars. """ + reference = self.env['payment.transaction']._compute_reference( + self.asiapay.provider, prefix='this is a long reference of more than 35 characters' + ) + self.assertEqual(reference, 'this is a long refer-20111102120021') + self.assertEqual(len(reference), 35) + + def test_no_item_missing_from_rendering_values(self): + """ Test that the rendered values are conform to the transaction fields. """ + tx = self._create_transaction(flow='redirect') + with patch( + 'odoo.addons.payment_asiapay.models.payment_acquirer.PaymentAcquirer' + '._asiapay_calculate_signature', return_value='dummy_signature' + ): + rendering_values = tx._get_specific_rendering_values(None) + self.assertDictEqual( + rendering_values, + { + 'amount': tx.amount, + 'api_url': tx.acquirer_id._asiapay_get_api_url(), + 'currency_code': const.CURRENCY_MAPPING[tx.currency_id.name], + 'language': const.LANGUAGE_CODES_MAPPING['en'], + 'merchant_id': tx.acquirer_id.asiapay_merchant_id, + 'mps_mode': 'SCP', + 'payment_method': 'ALL', + 'payment_type': 'N', + 'reference': tx.reference, + 'return_url': self._build_url('/payment/asiapay/return'), + 'secure_hash': 'dummy_signature', + } + ) + + @mute_logger('odoo.addons.payment.models.payment_transaction') + def test_no_input_missing_from_redirect_form(self): + """ Test that no key is omitted from the rendering values. """ + tx = self._create_transaction(flow='redirect') + expected_input_keys = [ + 'merchantId', + 'amount', + 'orderRef', + 'currCode', + 'mpsMode', + 'successUrl', + 'failUrl', + 'cancelUrl', + 'payType', + 'lang', + 'payMethod', + 'secureHash', + ] + processing_values = tx._get_processing_values() + form_info = self._extract_values_from_html_form(processing_values['redirect_form_html']) + self.assertEqual(form_info['action'], tx.acquirer_id._asiapay_get_api_url()) + self.assertEqual(form_info['method'], 'post') + self.assertListEqual(list(form_info['inputs'].keys()), expected_input_keys) + + def test_processing_notification_data_confirms_transaction(self): + """ Test that the transaction state is set to 'done' when the notification data indicate a + successful payment. """ + tx = self._create_transaction(flow='redirect') + tx._process_notification_data(self.webhook_notification_data) + self.assertEqual(tx.state, 'done') diff --git a/addons/payment_asiapay/tests/test_processing_flows.py b/addons/payment_asiapay/tests/test_processing_flows.py new file mode 100644 index 00000000000..c7878fdcd9e --- /dev/null +++ b/addons/payment_asiapay/tests/test_processing_flows.py @@ -0,0 +1,71 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from unittest.mock import patch + +from werkzeug.exceptions import Forbidden + +from odoo.tests import tagged +from odoo.tools import mute_logger + +from odoo.addons.payment.tests.http_common import PaymentHttpCommon +from odoo.addons.payment_asiapay.controllers.main import AsiaPayController +from odoo.addons.payment_asiapay.tests.common import AsiaPayCommon + + +@tagged('post_install', '-at_install') +class TestProcessingFlows(AsiaPayCommon, PaymentHttpCommon): + + @mute_logger('odoo.addons.payment_asiapay.controllers.main') + def test_webhook_notification_triggers_processing(self): + """ Test that receiving a valid webhook notification triggers the processing of the + notification data. """ + self._create_transaction('redirect') + url = self._build_url(AsiaPayController._webhook_url) + with patch( + 'odoo.addons.payment_asiapay.controllers.main.AsiaPayController.' + '_verify_notification_signature' + ), patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ) as handle_notification_data_mock: + self._make_http_post_request(url, data=self.webhook_notification_data) + self.assertEqual(handle_notification_data_mock.call_count, 1) + + @mute_logger('odoo.addons.payment_asiapay.controllers.main') + def test_webhook_notification_triggers_signature_check(self): + """ Test that receiving a webhook notification triggers a signature check. """ + self._create_transaction('redirect') + url = self._build_url(AsiaPayController._webhook_url) + with patch( + 'odoo.addons.payment_asiapay.controllers.main.AsiaPayController' + '._verify_notification_signature' + ) as signature_check_mock, patch( + 'odoo.addons.payment.models.payment_transaction.PaymentTransaction' + '._handle_notification_data' + ): + self._make_http_post_request(url, data=self.webhook_notification_data) + self.assertEqual(signature_check_mock.call_count, 1) + + def test_accept_webhook_notification_with_valid_signature(self): + """ Test the verification of a webhook notification with a valid signature. """ + tx = self._create_transaction('redirect') + self._assert_does_not_raise( + Forbidden, + AsiaPayController._verify_notification_signature, + self.webhook_notification_data, + tx, + ) + + @mute_logger('odoo.addons.payment_asiapay.controllers.main') + def test_reject_notification_with_missing_signature(self): + """ Test the verification of a notification with a missing signature. """ + tx = self._create_transaction('redirect') + payload = dict(self.webhook_notification_data, secureHash='dummy') + self.assertRaises(Forbidden, AsiaPayController._verify_notification_signature, payload, tx) + + @mute_logger('odoo.addons.payment_asiapay.controllers.main') + def test_reject_notification_with_invalid_signature(self): + """ Test the verification of a notification with an invalid signature. """ + tx = self._create_transaction('redirect') + payload = dict(self.webhook_notification_data, secureHash='dummy') + self.assertRaises(Forbidden, AsiaPayController._verify_notification_signature, payload, tx) diff --git a/addons/payment_asiapay/views/payment_asiapay_templates.xml b/addons/payment_asiapay/views/payment_asiapay_templates.xml new file mode 100644 index 00000000000..6b63e3f9b24 --- /dev/null +++ b/addons/payment_asiapay/views/payment_asiapay_templates.xml @@ -0,0 +1,21 @@ + + + + + + diff --git a/addons/payment_asiapay/views/payment_views.xml b/addons/payment_asiapay/views/payment_views.xml new file mode 100644 index 00000000000..4a011d400e5 --- /dev/null +++ b/addons/payment_asiapay/views/payment_views.xml @@ -0,0 +1,30 @@ + + + + + AsiaPay Acquirer Form + payment.acquirer + + + + + + + + + + + + + +