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
+
+
+
+
+
+
+
+
+
+
+
+
+
+