From 2f5da9729adbb64f48a596a5c1bcea1d1a3b7661 Mon Sep 17 00:00:00 2001 From: "Tommy (tong)" Date: Tue, 10 May 2022 09:11:37 +0000 Subject: [PATCH] [ADD] payment_asiapay: add an integration to AsiaPay task-2845428 closes odoo/odoo#98441 Related: odoo/documentation#2628 Signed-off-by: Antoine Vandevenne (anv) Co-authored-by: Antoine Vandevenne (anv) --- addons/payment/data/payment_acquirer_data.xml | 14 ++ addons/payment_asiapay/README.md | 37 ++++ addons/payment_asiapay/__init__.py | 10 ++ addons/payment_asiapay/__manifest__.py | 20 +++ addons/payment_asiapay/const.py | 71 ++++++++ .../payment_asiapay/controllers/__init__.py | 3 + addons/payment_asiapay/controllers/main.py | 75 ++++++++ .../data/payment_acquirer_data.xml | 15 ++ addons/payment_asiapay/models/__init__.py | 5 + .../models/account_payment_method.py | 13 ++ .../models/payment_acquirer.py | 102 +++++++++++ .../models/payment_transaction.py | 166 ++++++++++++++++++ .../static/description/icon.png | Bin 0 -> 16372 bytes .../static/description/icon.svg | 25 +++ addons/payment_asiapay/tests/__init__.py | 6 + addons/payment_asiapay/tests/common.py | 34 ++++ .../tests/test_payment_acquirer.py | 47 +++++ .../tests/test_payment_transaction.py | 97 ++++++++++ .../tests/test_processing_flows.py | 71 ++++++++ .../views/payment_asiapay_templates.xml | 21 +++ .../payment_asiapay/views/payment_views.xml | 30 ++++ 21 files changed, 862 insertions(+) create mode 100644 addons/payment_asiapay/README.md create mode 100644 addons/payment_asiapay/__init__.py create mode 100644 addons/payment_asiapay/__manifest__.py create mode 100644 addons/payment_asiapay/const.py create mode 100644 addons/payment_asiapay/controllers/__init__.py create mode 100644 addons/payment_asiapay/controllers/main.py create mode 100644 addons/payment_asiapay/data/payment_acquirer_data.xml create mode 100644 addons/payment_asiapay/models/__init__.py create mode 100644 addons/payment_asiapay/models/account_payment_method.py create mode 100644 addons/payment_asiapay/models/payment_acquirer.py create mode 100644 addons/payment_asiapay/models/payment_transaction.py create mode 100644 addons/payment_asiapay/static/description/icon.png create mode 100644 addons/payment_asiapay/static/description/icon.svg create mode 100644 addons/payment_asiapay/tests/__init__.py create mode 100644 addons/payment_asiapay/tests/common.py create mode 100644 addons/payment_asiapay/tests/test_payment_acquirer.py create mode 100644 addons/payment_asiapay/tests/test_payment_transaction.py create mode 100644 addons/payment_asiapay/tests/test_processing_flows.py create mode 100644 addons/payment_asiapay/views/payment_asiapay_templates.xml create mode 100644 addons/payment_asiapay/views/payment_views.xml 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 0000000000000000000000000000000000000000..36f40b9aab26ee2dd78360acb88f7278f4a2ab56 GIT binary patch literal 16372 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 + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + + + + + + +