From 6b42234b8bab7a64f1b04428aa151ae1f3c36208 Mon Sep 17 00:00:00 2001 From: Arnaud Joset Date: Fri, 26 Mar 2021 18:03:40 +0100 Subject: [PATCH] [REF] payment_alipay: migrate Alipay to the new payment API See the merge commit for more details. task-2333027 --- addons/payment_alipay/__init__.py | 9 +- addons/payment_alipay/__manifest__.py | 7 +- addons/payment_alipay/controllers/__init__.py | 1 - addons/payment_alipay/controllers/main.py | 87 +++--- .../data/payment_acquirer_data.xml | 9 +- addons/payment_alipay/models/__init__.py | 4 +- addons/payment_alipay/models/payment.py | 226 ---------------- .../payment_alipay/models/payment_acquirer.py | 60 +++++ .../models/payment_transaction.py | 168 ++++++++++++ addons/payment_alipay/tests/__init__.py | 2 +- addons/payment_alipay/tests/common.py | 22 ++ addons/payment_alipay/tests/test_alipay.py | 249 +++++++++--------- .../views/payment_alipay_templates.xml | 9 +- .../{alipay_views.xml => payment_views.xml} | 11 +- 14 files changed, 445 insertions(+), 419 deletions(-) delete mode 100644 addons/payment_alipay/models/payment.py create mode 100644 addons/payment_alipay/models/payment_acquirer.py create mode 100644 addons/payment_alipay/models/payment_transaction.py create mode 100644 addons/payment_alipay/tests/common.py rename addons/payment_alipay/views/{alipay_views.xml => payment_views.xml} (56%) diff --git a/addons/payment_alipay/__init__.py b/addons/payment_alipay/__init__.py index e52ad6f83e3..eddb86ff1c1 100644 --- a/addons/payment_alipay/__init__.py +++ b/addons/payment_alipay/__init__.py @@ -1,6 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import controllers from . import models -from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers + +from odoo.addons.payment import reset_payment_acquirer +from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook + + +def uninstall_hook(cr, registry): + reset_payment_acquirer(cr, registry, 'alipay') diff --git a/addons/payment_alipay/__manifest__.py b/addons/payment_alipay/__manifest__.py index 0603f5071ec..442c2a152d0 100644 --- a/addons/payment_alipay/__manifest__.py +++ b/addons/payment_alipay/__manifest__.py @@ -1,18 +1,19 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'Alipay Payment Acquirer', 'category': 'Accounting/Payment Acquirers', + 'version': '2.0', 'sequence': 345, 'summary': 'Payment Acquirer: Alipay Implementation', 'description': """Alipay Payment Acquirer""", 'depends': ['payment'], 'data': [ - 'views/alipay_views.xml', 'views/payment_alipay_templates.xml', + 'views/payment_views.xml', 'data/payment_acquirer_data.xml', ], 'application': True, - 'post_init_hook': 'create_missing_journal_for_acquirers', + 'post_init_hook': 'create_missing_journals', + 'uninstall_hook': 'uninstall_hook', } diff --git a/addons/payment_alipay/controllers/__init__.py b/addons/payment_alipay/controllers/__init__.py index 5d4b25db9c0..80ee4da1c5e 100644 --- a/addons/payment_alipay/controllers/__init__.py +++ b/addons/payment_alipay/controllers/__init__.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. from . import main diff --git a/addons/payment_alipay/controllers/main.py b/addons/payment_alipay/controllers/main.py index 5763313b1a9..bac1c3c7422 100644 --- a/addons/payment_alipay/controllers/main.py +++ b/addons/payment_alipay/controllers/main.py @@ -1,63 +1,62 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import pprint + import requests import werkzeug -from odoo import http +from odoo import _, http +from odoo.exceptions import ValidationError from odoo.http import request _logger = logging.getLogger(__name__) class AlipayController(http.Controller): - _notify_url = '/payment/alipay/notify' _return_url = '/payment/alipay/return' + _notify_url = '/payment/alipay/notify' - def _alipay_validate_data(self, **post): - resp = post.get('trade_status') - if resp: - if resp in ['TRADE_FINISHED', 'TRADE_SUCCESS']: - _logger.info('Alipay: validated data') - elif resp == 'TRADE_CLOSED': - _logger.warning('Alipay: payment refunded to user and closed the transaction') - else: - _logger.warning('Alipay: unrecognized alipay answer, received %s instead of TRADE_FINISHED/TRADE_SUCCESS and TRADE_CLOSED' % (post['trade_status'])) - if post.get('out_trade_no') and post.get('trade_no'): - post['reference'] = request.env['payment.transaction'].sudo().search([('reference', '=', post['out_trade_no'])]).reference - return request.env['payment.transaction'].sudo().form_feedback(post, 'alipay') - return False - - def _alipay_validate_notification(self, **post): - if post.get('out_trade_no'): - alipay = request.env['payment.transaction'].sudo().search([('reference', '=', post.get('out_trade_no'))]).acquirer_id - else: - alipay = request.env['payment.acquirer'].sudo().search([('provider', '=', 'alipay')]) - val = { - 'service': 'notify_verify', - 'partner': alipay.alipay_merchant_partner_id, - 'notify_id': post['notify_id'] - } - response = requests.post(alipay.alipay_get_form_action_url(), val) - response.raise_for_status() - _logger.info('Validate alipay Notification %s' % response.text) - # After program is executed, the page must print “success” (without quote). If not, Alipay server would keep re-sending notification, until over 24 hour 22 minutes Generally, there are 8 notifications within 25 hours (Frequency: 2m,10m,15m,1h,2h,6h,15h) - if response.text == 'true': - self._alipay_validate_data(**post) - return 'success' - return "" - - @http.route('/payment/alipay/return', type='http', auth="public", methods=['GET', 'POST']) - def alipay_return(self, **post): + @http.route(_return_url, type='http', auth="public", methods=['GET']) + def alipay_return_from_redirect(self, **data): """ Alipay return """ - _logger.info('Beginning Alipay form_feedback with post data %s', pprint.pformat(post)) - self._alipay_validate_data(**post) - return werkzeug.utils.redirect('/payment/process') + _logger.info("received Alipay return data:\n%s", pprint.pformat(data)) + request.env['payment.transaction'].sudo()._handle_feedback_data('alipay', data) + return werkzeug.utils.redirect('/payment/status') - @http.route('/payment/alipay/notify', type='http', auth='public', methods=['POST'], csrf=False) + @http.route(_notify_url, type='http', auth='public', methods=['POST'], csrf=False) def alipay_notify(self, **post): """ Alipay Notify """ - _logger.info('Beginning Alipay notification form_feedback with post data %s', pprint.pformat(post)) - return self._alipay_validate_notification(**post) + _logger.info("received Alipay notification data:\n%s", pprint.pformat(post)) + self._alipay_validate_notification(**post) + request.env['payment.transaction'].sudo()._handle_feedback_data('alipay', post) + return 'success' # Return 'success' to stop receiving notifications for this tx + + def _alipay_validate_notification(self, **post): + tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_feedback_data( + 'alipay', post + ) + if not tx_sudo: + raise ValidationError( + "Alipay: " + _( + "Received notification data with unknown reference:\n%s", pprint.pformat(post) + ) + ) + + # Ensure that the notification was sent by Alipay + # See https://global.alipay.com/docs/ac/wap/async + acquirer_sudo = tx_sudo.acquirer_id + val = { + 'service': 'notify_verify', + 'partner': acquirer_sudo.alipay_merchant_partner_id, + 'notify_id': post['notify_id'] + } + response = requests.post(acquirer_sudo._alipay_get_api_url(), val, timeout=60) + response.raise_for_status() + if response.text != 'true': + raise ValidationError( + "Alipay: " + _( + "Received notification data not acknowledged by Alipay:\n%s", + pprint.pformat(post) + ) + ) diff --git a/addons/payment_alipay/data/payment_acquirer_data.xml b/addons/payment_alipay/data/payment_acquirer_data.xml index a9343819efc..a8d9a1f578c 100644 --- a/addons/payment_alipay/data/payment_acquirer_data.xml +++ b/addons/payment_alipay/data/payment_acquirer_data.xml @@ -1,9 +1,12 @@ + - Alipay alipay - - + + False + True + False + diff --git a/addons/payment_alipay/models/__init__.py b/addons/payment_alipay/models/__init__.py index 2ec5b9cd2f6..8e04237423a 100644 --- a/addons/payment_alipay/models/__init__.py +++ b/addons/payment_alipay/models/__init__.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import payment +from . import payment_acquirer +from . import payment_transaction diff --git a/addons/payment_alipay/models/payment.py b/addons/payment_alipay/models/payment.py deleted file mode 100644 index 326f0ddcaec..00000000000 --- a/addons/payment_alipay/models/payment.py +++ /dev/null @@ -1,226 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -import logging - -from hashlib import md5 -from werkzeug import urls - -from odoo import api, fields, models, _ -from odoo.tools.float_utils import float_compare -from odoo.addons.payment_alipay.controllers.main import AlipayController -from odoo.addons.payment.models.payment_acquirer import ValidationError - -_logger = logging.getLogger(__name__) - - -class PaymentAcquirer(models.Model): - _inherit = 'payment.acquirer' - - provider = fields.Selection(selection_add=[ - ('alipay', 'Alipay') - ], ondelete={'alipay': 'set default'}) - alipay_payment_method = fields.Selection([ - ('express_checkout', 'Express Checkout (only for Chinese Merchant)'), - ('standard_checkout', 'Cross-border'), - ], string='Account', default='express_checkout', - help=" * Cross-border: For the Overseas seller \n * Express Checkout: For the Chinese Seller") - alipay_merchant_partner_id = fields.Char( - string='Merchant Partner ID', required_if_provider='alipay', groups='base.group_user', - help='The Merchant Partner ID is used to ensure communications coming from Alipay are valid and secured.') - alipay_md5_signature_key = fields.Char( - string='MD5 Signature Key', required_if_provider='alipay', groups='base.group_user', - help="The MD5 private key is the 32-byte string which is composed of English letters and numbers.") - alipay_seller_email = fields.Char(string='Alipay Seller Email', groups='base.group_user') - - def _get_feature_support(self): - res = super(PaymentAcquirer, self)._get_feature_support() - res['fees'].append('alipay') - return res - - @api.model - def _get_alipay_urls(self, environment): - """ Alipay URLS """ - if environment == 'prod': - return 'https://mapi.alipay.com/gateway.do' - return 'https://openapi.alipaydev.com/gateway.do' - - def alipay_compute_fees(self, amount, currency_id, country_id): - """ Compute alipay fees. - - :param float amount: the amount to pay - :param integer country_id: an ID of a res.country, or None. This is - the customer's country, to be compared to - the acquirer company country. - :return float fees: computed fees - """ - fees = 0.0 - if self.fees_active: - country = self.env['res.country'].browse(country_id) - if country and self.company_id.sudo().country_id.id == country.id: - percentage = self.fees_dom_var - fixed = self.fees_dom_fixed - else: - percentage = self.fees_int_var - fixed = self.fees_int_fixed - fees = (percentage / 100.0 * amount + fixed) / (1 - percentage / 100.0) - return fees - - def _build_sign(self, val): - # Rearrange parameters in the data set alphabetically - data_to_sign = sorted(val.items()) - # Exclude parameters that should not be signed - data_to_sign = ["{}={}".format(k, v) for k, v in data_to_sign if k not in ['sign', 'sign_type', 'reference']] - # And connect rearranged parameters with & - data_string = '&'.join(data_to_sign) - data_string += self.alipay_md5_signature_key - return md5(data_string.encode('utf-8')).hexdigest() - - def _get_alipay_tx_values(self, values): - base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') - - alipay_tx_values = ({ - '_input_charset': 'utf-8', - 'notify_url': urls.url_join(base_url, AlipayController._notify_url), - 'out_trade_no': values.get('reference'), - 'partner': self.alipay_merchant_partner_id, - 'return_url': urls.url_join(base_url, AlipayController._return_url), - 'subject': values.get('reference'), - 'total_fee': values.get('amount') + values.get('fees'), - }) - if self.alipay_payment_method == 'standard_checkout': - alipay_tx_values.update({ - 'service': 'create_forex_trade', - 'product_code': 'NEW_OVERSEAS_SELLER', - 'currency': values.get('currency').name, - }) - else: - alipay_tx_values.update({ - 'service': 'create_direct_pay_by_user', - 'payment_type': 1, - 'seller_email': self.alipay_seller_email, - }) - sign = self._build_sign(alipay_tx_values) - alipay_tx_values.update({ - 'sign_type': 'MD5', - 'sign': sign, - }) - return alipay_tx_values - - def alipay_form_generate_values(self, values): - values.update(self._get_alipay_tx_values(values)) - return values - - def alipay_get_form_action_url(self): - self.ensure_one() - environment = 'prod' if self.state == 'enabled' else 'test' - return self._get_alipay_urls(environment) - - -class PaymentTransaction(models.Model): - _inherit = 'payment.transaction' - - def _check_alipay_configuration(self, vals): - acquirer_id = int(vals.get('acquirer_id')) - acquirer = self.env['payment.acquirer'].sudo().browse(acquirer_id) - if acquirer and acquirer.provider == 'alipay' and acquirer.alipay_payment_method == 'express_checkout': - currency_id = int(vals.get('currency_id')) - if currency_id: - currency = self.env['res.currency'].sudo().browse(currency_id) - if currency and currency.name != 'CNY': - _logger.info("Only CNY currency is allowed for Alipay Express Checkout") - raise ValidationError(_(""" - Only transactions in Chinese Yuan (CNY) are allowed for Alipay Express Checkout.\n - If you wish to use another currency than CNY for your transactions, switch your - configuration to a Cross-border account on the Alipay payment acquirer in Odoo. - """)) - return True - - def write(self, vals): - if vals.get('currency_id') or vals.get('acquirer_id'): - for payment in self: - check_vals = { - 'acquirer_id': vals.get('acquirer_id', payment.acquirer_id.id), - 'currency_id': vals.get('currency_id', payment.currency_id.id) - } - payment._check_alipay_configuration(check_vals) - return super(PaymentTransaction, self).write(vals) - - @api.model - def create(self, vals): - self._check_alipay_configuration(vals) - return super(PaymentTransaction, self).create(vals) - - # -------------------------------------------------- - # FORM RELATED METHODS - # -------------------------------------------------- - - @api.model - def _alipay_form_get_tx_from_data(self, data): - reference, txn_id, sign = data.get('reference'), data.get('trade_no'), data.get('sign') - if not reference or not txn_id: - _logger.info('Alipay: received data with missing reference (%s) or txn_id (%s)' % (reference, txn_id)) - raise ValidationError(_('Alipay: received data with missing reference (%s) or txn_id (%s)') % (reference, txn_id)) - - txs = self.env['payment.transaction'].search([('reference', '=', reference)]) - if not txs or len(txs) > 1: - error_msg = _('Alipay: received data for reference %s') % (reference) - logger_msg = 'Alipay: received data for reference %s' % (reference) - if not txs: - error_msg += _('; no order found') - logger_msg += '; no order found' - else: - error_msg += _('; multiple order found') - logger_msg += '; multiple order found' - _logger.info(logger_msg) - raise ValidationError(error_msg) - - # verify sign - sign_check = txs.acquirer_id._build_sign(data) - if sign != sign_check: - _logger.info('Alipay: invalid sign, received %s, computed %s, for data %s' % (sign, sign_check, data)) - raise ValidationError(_('Alipay: invalid sign, received %s, computed %s, for data %s') % (sign, sign_check, data)) - - return txs - - def _alipay_form_get_invalid_parameters(self, data): - invalid_parameters = [] - - if float_compare(float(data.get('total_fee', '0.0')), (self.amount + self.fees), 2) != 0: - invalid_parameters.append(('total_fee', data.get('total_fee'), '%.2f' % (self.amount + self.fees))) # mc_gross is amount + fees - if self.acquirer_id.alipay_payment_method == 'standard_checkout': - if data.get('currency') != self.currency_id.name: - invalid_parameters.append(('currency', data.get('currency'), self.currency_id.name)) - else: - if data.get('seller_email') != self.acquirer_id.alipay_seller_email: - invalid_parameters.append(('seller_email', data.get('seller_email'), self.acquirer_id.alipay_seller_email)) - return invalid_parameters - - def _alipay_form_validate(self, data): - if self.state in ['done']: - _logger.info('Alipay: trying to validate an already validated tx (ref %s)', self.reference) - return True - - status = data.get('trade_status') - res = { - 'acquirer_reference': data.get('trade_no'), - } - if status in ['TRADE_FINISHED', 'TRADE_SUCCESS']: - _logger.info('Validated Alipay payment for tx %s: set as done' % (self.reference)) - date_validate = fields.Datetime.now() - res.update(date=date_validate) - self._set_transaction_done() - self.write(res) - self.execute_callback() - return True - elif status == 'TRADE_CLOSED': - _logger.info('Received notification for Alipay payment %s: set as Canceled' % (self.reference)) - res.update(state_message=data.get('close_reason', '')) - self._set_transaction_cancel() - return self.write(res) - else: - error = 'Received unrecognized status for Alipay payment %s: %s, set as error' % (self.reference, status) - _logger.info(error) - res.update(state_message=error) - self._set_transaction_error() - return self.write(res) diff --git a/addons/payment_alipay/models/payment_acquirer.py b/addons/payment_alipay/models/payment_acquirer.py new file mode 100644 index 00000000000..291eab157f9 --- /dev/null +++ b/addons/payment_alipay/models/payment_acquirer.py @@ -0,0 +1,60 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +from hashlib import md5 + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection( + selection_add=[('alipay', "Alipay")], ondelete={'alipay': 'set default'}) + alipay_payment_method = fields.Selection( + string="Account", + help="* Cross-border: For the overseas seller \n* Express Checkout: For the Chinese Seller", + selection=[ + ('express_checkout', 'Express Checkout (only for Chinese merchants)'), + ('standard_checkout', 'Cross-border') + ], default='express_checkout', required_if_provider='alipay') + alipay_merchant_partner_id = fields.Char( + string="Merchant Partner ID", + help="The public partner ID solely used to identify the account with Alipay", + required_if_provider='alipay') + alipay_md5_signature_key = fields.Char( + string="MD5 Signature Key", required_if_provider='alipay', groups='base.group_system') + alipay_seller_email = fields.Char( + string="Alipay Seller Email", help="The public Alipay partner email") + + @api.model + def _get_compatible_acquirers(self, *args, currency_id=None, **kwargs): + """ Override of payment to unlist Alipay 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 and currency.name != 'CNY': + acquirers = acquirers.filtered( + lambda a: a.provider != 'alipay' or a.alipay_payment_method != 'express_checkout' + ) + + return acquirers + + def _alipay_build_sign(self, val): + # Rearrange parameters in the data set alphabetically + data_to_sign = sorted(val.items()) + # Format key-value pairs of parameters that should be signed + data_to_sign = [f"{k}={v}" for k, v in data_to_sign + if k not in ['sign', 'sign_type', 'reference']] + # Build the data string of &-separated key-value pairs + data_string = '&'.join(data_to_sign) + data_string += self.alipay_md5_signature_key + return md5(data_string.encode('utf-8')).hexdigest() + + def _alipay_get_api_url(self): + if self.state == 'enabled': + return 'https://mapi.alipay.com/gateway.do' + else: # test environment + return 'https://openapi.alipaydev.com/gateway.do' diff --git a/addons/payment_alipay/models/payment_transaction.py b/addons/payment_alipay/models/payment_transaction.py new file mode 100644 index 00000000000..bd660bc62a7 --- /dev/null +++ b/addons/payment_alipay/models/payment_transaction.py @@ -0,0 +1,168 @@ +# 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.tools.float_utils import float_compare + +from odoo.addons.payment_alipay.controllers.main import AlipayController + +_logger = logging.getLogger(__name__) + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + def _get_specific_rendering_values(self, processing_values): + """ Override of payment to return Alipay-specific rendering values. + + Note: self.ensure_one() from `_get_processing_values` + + :param dict processing_values: The generic and specific processing values of the transaction + :return: The dict of acquirer-specific processing values + :rtype: dict + """ + res = super()._get_specific_rendering_values(processing_values) + if self.provider != 'alipay': + return res + + base_url = self.acquirer_id._get_base_url() + if self.fees: + # Similarly to what is done in `payment::payment.transaction.create`, we need to round + # the sum of the amount and of the fees to avoid inconsistent string representations. + # E.g., str(1111.11 + 7.09) == '1118.1999999999998' + total_fee = self.currency_id.round(self.amount + self.fees) + else: + total_fee = self.amount + rendering_values = { + '_input_charset': 'utf-8', + 'notify_url': urls.url_join(base_url, AlipayController._notify_url), + 'out_trade_no': self.reference, + 'partner': self.acquirer_id.alipay_merchant_partner_id, + 'return_url': urls.url_join(base_url, AlipayController._return_url), + 'subject': self.reference, + 'total_fee': total_fee, + } + if self.acquirer_id.alipay_payment_method == 'standard_checkout': + # https://global.alipay.com/docs/ac/global/create_forex_trade + rendering_values.update({ + 'service': 'create_forex_trade', + 'product_code': 'NEW_OVERSEAS_SELLER', + 'currency': self.currency_id.name, + }) + else: + rendering_values.update({ + 'service': 'create_direct_pay_by_user', + 'payment_type': 1, + 'seller_email': self.acquirer_id.alipay_seller_email, + }) + + sign = self.acquirer_id._alipay_build_sign(rendering_values) + rendering_values.update({ + 'sign_type': 'MD5', + 'sign': sign, + 'api_url': self.acquirer_id._alipay_get_api_url(), + }) + return rendering_values + + @api.model + def _get_tx_from_feedback_data(self, provider, data): + """ Override of payment to find the transaction based on Alipay data. + + :param str provider: The provider of the acquirer that handled the transaction + :param dict data: The feedback data sent by the provider + :return: The transaction if found + :rtype: recordset of `payment.transaction` + :raise: ValidationError if inconsistent data were received + :raise: ValidationError if the data match no transaction + """ + tx = super()._get_tx_from_feedback_data(provider, data) + if provider != 'alipay': + return tx + + reference = data.get('reference') or data.get('out_trade_no') + txn_id = data.get('trade_no') + if not reference or not txn_id: + raise ValidationError( + "Alipay: " + _( + "Received data with missing reference %(r)s or txn_id %(t)s.", + r=reference, t=txn_id + ) + ) + + tx = self.search([('reference', '=', reference), ('provider', '=', 'alipay')]) + if not tx: + raise ValidationError( + "Alipay: " + _("No transaction found matching reference %s.", reference) + ) + + # Verify signature (done here because we need the reference to get the acquirer) + sign_check = tx.acquirer_id._alipay_build_sign(data) + sign = data.get('sign') + if sign != sign_check: + raise ValidationError( + "Alipay: " + _( + "Expected signature %(sc) but received %(sign)s.", sc=sign_check, sign=sign + ) + ) + + return tx + + def _process_feedback_data(self, data): + """ Override of payment to process the transaction based on Alipay data. + + Note: self.ensure_one() + + :param dict data: The feedback data sent by the provider + :return: None + :raise: ValidationError if inconsistent data were received + """ + super()._process_feedback_data(data) + if self.provider != 'alipay': + return + + if float_compare(float(data.get('total_fee', '0.0')), (self.amount + self.fees), 2) != 0: + # mc_gross is amount + fees + logging_values = { + 'amount': data.get('total_fee', '0.0'), + 'total': self.amount, + 'fees': self.fees, + 'reference': self.reference, + } + _logger.error( + "the paid amount (%(amount)s) does not match the total + fees (%(total)s + " + "%(fees)s) for the transaction with reference %(reference)s", logging_values + ) + raise ValidationError("Alipay: " + _("The amount does not match the total + fees.")) + if self.acquirer_id.alipay_payment_method == 'standard_checkout': + if data.get('currency') != self.currency_id.name: + raise ValidationError( + "Alipay: " + _( + "The currency returned by Alipay %(rc)s does not match the transaction " + "currency %(tc)s.", rc=data.get('currency'), tc=self.currency_id.name + ) + ) + elif data.get('seller_email') != self.acquirer_id.alipay_seller_email: + _logger.error( + "the seller email (%s) does not match the configured Alipay account (%s).", + data.get('seller_email'), self.acquirer_id.alipay_seller_email + ) + raise ValidationError( + "Alipay: " + _("The seller email does not match the configured Alipay account.") + ) + + self.acquirer_reference = data.get('trade_no') + status = data.get('trade_status') + if status in ['TRADE_FINISHED', 'TRADE_SUCCESS']: + self._set_done() + elif status == 'TRADE_CLOSED': + self._set_canceled() + else: + _logger.info( + "received invalid transaction status for transaction with reference %s: %s", + self.reference, status + ) + self._set_error("Alipay: " + _("received invalid transaction status: %s", status)) diff --git a/addons/payment_alipay/tests/__init__.py b/addons/payment_alipay/tests/__init__.py index bf53571ae82..620325b988a 100644 --- a/addons/payment_alipay/tests/__init__.py +++ b/addons/payment_alipay/tests/__init__.py @@ -1,4 +1,4 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from . import common from . import test_alipay diff --git a/addons/payment_alipay/tests/common.py b/addons/payment_alipay/tests/common.py new file mode 100644 index 00000000000..8e28fce224d --- /dev/null +++ b/addons/payment_alipay/tests/common.py @@ -0,0 +1,22 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.addons.payment.tests.common import PaymentCommon + + +class AlipayCommon(PaymentCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.currency_yuan = cls._prepare_currency('CNY') + cls.alipay = cls._prepare_acquirer('alipay', update_values={ + 'alipay_merchant_partner_id': 'dummy', + 'alipay_md5_signature_key': 'dummy', + 'alipay_seller_email': 'dummy', + 'fees_active': False, # Only activate fees in dedicated tests + }) + + # override defaults for helpers + cls.acquirer = cls.alipay + cls.currency = cls.currency_yuan diff --git a/addons/payment_alipay/tests/test_alipay.py b/addons/payment_alipay/tests/test_alipay.py index b1dfd720118..23b01725bc1 100644 --- a/addons/payment_alipay/tests/test_alipay.py +++ b/addons/payment_alipay/tests/test_alipay.py @@ -1,87 +1,100 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from werkzeug import urls -from lxml import objectify - -import odoo - +from odoo.exceptions import ValidationError +from odoo.tests import tagged from odoo.tools import mute_logger -from odoo.addons.payment.models.payment_acquirer import ValidationError -from odoo.addons.payment.tests.common import PaymentAcquirerCommon -from odoo.addons.payment_alipay.controllers.main import AlipayController -@odoo.tests.tagged('post_install', '-at_install', 'external', '-standard') -class AlipayTest(PaymentAcquirerCommon): +from .common import AlipayCommon +from ..controllers.main import AlipayController - @classmethod - def setUpClass(cls, chart_template_ref=None): - super().setUpClass(chart_template_ref=chart_template_ref) - cls.currency_yuan = cls.env['res.currency'].search([('name', '=', 'CNY'), - '|', - ('active', '=', True), - ('active', '=', False)], limit=1) - cls.alipay = cls.env.ref('payment.payment_acquirer_alipay') - cls.alipay.write({ - 'alipay_merchant_partner_id': 'dummy', - 'alipay_md5_signature_key': 'dummy', - 'alipay_seller_email': 'dummy', - 'state': 'test', - }) +@tagged('post_install', '-at_install') +class AlipayTest(AlipayCommon): - def test_10_alipay_form_render(self): - base_url = self.env['ir.config_parameter'].get_param('web.base.url') - self.assertEqual(self.alipay.state, 'test', 'test without test environment') + def test_compatible_acquirers(self): + self.alipay.alipay_payment_method = 'express_checkout' + acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + partner_id=self.partner.id, + currency_id=self.currency_yuan.id, # 'CNY' + company_id=self.company.id, + ) + self.assertIn(self.alipay, acquirers) + acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + partner_id=self.partner.id, + currency_id=self.currency_euro.id, + company_id=self.company.id, + ) + self.assertNotIn(self.alipay, acquirers) - # ---------------------------------------- - # Test: button direct rendering - # ---------------------------------------- + self.alipay.alipay_payment_method = 'standard_checkout' + acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + partner_id=self.partner.id, + currency_id=self.currency_yuan.id, # 'CNY' + company_id=self.company.id, + ) + self.assertIn(self.alipay, acquirers) + acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + partner_id=self.partner.id, + currency_id=self.currency_euro.id, + company_id=self.company.id, + ) + self.assertIn(self.alipay, acquirers) - # render the button - res = self.alipay.render( - 'test_ref0', 0.01, self.currency_euro.id, - values=self.buyer_values) + def test_01_redirect_form_standard_checkout(self): + self.alipay.alipay_payment_method = 'standard_checkout' + self._test_alipay_redirect_form() - form_values = { + def test_02_redirect_form_express_checkout(self): + self.alipay.alipay_payment_method = 'express_checkout' + self._test_alipay_redirect_form() + + def _test_alipay_redirect_form(self): + tx = self.create_transaction(flow='redirect') # Only flow implemented + + expected_values = { '_input_charset': 'utf-8', - 'notify_url': urls.url_join(base_url, AlipayController._notify_url), - 'out_trade_no': 'SO12345-1', + 'notify_url': self._build_url(AlipayController._notify_url), + 'out_trade_no': self.reference, 'partner': self.alipay.alipay_merchant_partner_id, - 'return_url': urls.url_join(base_url, AlipayController._return_url), - 'subject': 'test_ref0', - 'total_fee': '0.01', + 'return_url': self._build_url(AlipayController._return_url), + 'subject': self.reference, + 'total_fee': str(self.amount), # Fees disabled by default } if self.alipay.alipay_payment_method == 'standard_checkout': - form_values.update({ + expected_values.update({ 'service': 'create_forex_trade', - 'currency': 'EUR', 'product_code': 'NEW_OVERSEAS_SELLER', + 'currency': self.currency_yuan.name, }) else: - form_values.update({ - 'payment_type': '1', + expected_values.update({ + 'service': 'create_direct_pay_by_user', + 'payment_type': str(1), 'seller_email': self.alipay.alipay_seller_email, - 'service': 'create_direct_pay_by_user' }) - sign = self.alipay._build_sign(form_values) + sign = self.alipay._alipay_build_sign(expected_values) - form_values.update({'sign': sign, 'sign_type': 'MD5'}) - # check form result - tree = objectify.fromstring(res) + with mute_logger('odoo.addons.payment.models.payment_transaction'): + processing_values = tx._get_processing_values() + redirect_form_data = self._extract_values_from_html_form(processing_values['redirect_form_html']) - data_set = tree.xpath("//input[@name='data_set']") - self.assertEqual(len(data_set), 1, 'Alipay: Found %d "data_set" input instead of 1' % len(data_set)) - self.assertEqual(data_set[0].get('data-action-url'), 'https://openapi.alipaydev.com/gateway.do', 'alipay: wrong form POST url') - for form_input in tree.input: - if form_input.get('name') in ['submit', 'data_set', 'sign', 'out_trade_no']: - continue - self.assertEqual(form_input.get('value'), form_values[form_input.get('name')], 'alipay: wrong value for input %s: received %s instead of %s' % (form_input.get('name'), form_input.get('value'), form_values[form_input.get('name')])) + expected_values.update({ + 'sign': sign, + 'sign_type': 'MD5', + }) - def test_11_alipay_form_with_fees(self): - self.assertEqual(self.alipay.state, 'test', 'test without test environment') + self.assertEqual( + redirect_form_data['action'], + 'https://openapi.alipaydev.com/gateway.do', + ) + self.assertDictEqual( + expected_values, + redirect_form_data['inputs'], + "Alipay: invalid inputs specified in the redirect form.", + ) + def test_03_redirect_form_with_fees(self): # update acquirer: compute fees self.alipay.write({ 'fees_active': True, @@ -91,34 +104,41 @@ class AlipayTest(PaymentAcquirerCommon): 'fees_int_var': 0.50, }) - # render the button - res = self.alipay.render( - 'test_ref0', 12.50, self.currency_euro.id, - values=self.buyer_values) + transaction_fees = self.currency.round( + self.alipay._compute_fees( + self.amount, + self.currency, + self.partner.country_id, + ) + ) + self.assertEqual(transaction_fees, 7.09) + total_fee = self.currency.round(self.amount + transaction_fees) + self.assertEqual(total_fee, 1118.2) - tree = objectify.fromstring(res) + tx = self.create_transaction(flow='redirect') + self.assertEqual(tx.fees, 7.09) + with mute_logger('odoo.addons.payment.models.payment_transaction'): + processing_values = tx._get_processing_values() + redirect_form_data = self._extract_values_from_html_form(processing_values['redirect_form_html']) - data_set = tree.xpath("//input[@name='data_set']") - self.assertEqual(len(data_set), 1, 'alipay: Found %d "data_set" input instead of 1' % len(data_set)) - self.assertEqual(data_set[0].get('data-action-url'), 'https://openapi.alipaydev.com/gateway.do', 'alipay: wrong form POST url') - for form_input in tree.input: - if form_input.get('name') in ['total_fee']: - self.assertEqual(form_input.get('value'), '14.07', 'alipay: wrong computed fees') # total amount = amount + fees + self.assertEqual(redirect_form_data['inputs']['total_fee'], str(total_fee)) - @mute_logger('odoo.addons.payment_alipay.models.payment', 'ValidationError') - def test_20_alipay_form_management(self): + def test_21_standard_checkout_feedback(self): self.alipay.alipay_payment_method = 'standard_checkout' - self._test_20_alipay_form_management() + self.currency = self.currency_euro + self._test_alipay_feedback_processing() + + def test_22_express_checkout_feedback(self): self.alipay.alipay_payment_method = 'express_checkout' - self._test_20_alipay_form_management() - - def _test_20_alipay_form_management(self): - self.assertEqual(self.alipay.state, 'test', 'test without test environment') + self.currency = self.currency_yuan + self._test_alipay_feedback_processing() + def _test_alipay_feedback_processing(self): # typical data posted by alipay after client has successfully paid + custom_reference = 'test_ref_' + self.alipay.alipay_payment_method alipay_post_data = { 'trade_no': '2017112321001003690200384552', - 'reference': 'test_ref_' + self.alipay.alipay_payment_method, + 'reference': custom_reference, 'total_fee': 1.95, 'trade_status': 'TRADE_CLOSED', } @@ -132,33 +152,25 @@ class AlipayTest(PaymentAcquirerCommon): 'currency': 'EUR', }) - alipay_post_data['sign'] = self.alipay._build_sign(alipay_post_data) - # should raise error about unknown tx - with self.assertRaises(ValidationError): - self.env['payment.transaction'].form_feedback(alipay_post_data, 'alipay') + alipay_post_data['sign'] = self.alipay._alipay_build_sign(alipay_post_data) + with self.assertRaises(ValidationError): # unknown transactiion + self.env['payment.transaction']._handle_feedback_data('alipay', alipay_post_data) - if self.alipay.alipay_payment_method == 'express_checkout': - currency = self.currency_yuan - else: - currency = self.currency_euro - - # create tx tx = self.env['payment.transaction'].create({ 'amount': 1.95, 'acquirer_id': self.alipay.id, - 'currency_id': currency.id, - 'reference': 'test_ref_' + self.alipay.alipay_payment_method, - 'partner_name': 'Norbert Buyer', - 'partner_country_id': self.country_france.id + 'currency_id': self.currency.id, + 'reference': custom_reference, + 'partner_id': self.partner.id }) - # validate tx - tx.form_feedback(alipay_post_data, 'alipay') - # check tx - self.assertEqual(tx.state, 'cancel', 'alipay: wrong state after receiving a valid pending notification') - self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', 'alipay: wrong txn_id after receiving a valid pending notification') + self.env['payment.transaction']._handle_feedback_data('alipay', alipay_post_data) + self.assertEqual(tx.state, 'cancel', + 'Alipay: wrong state after receiving a valid pending notification') + self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', + 'Alipay: wrong txn_id after receiving a valid pending notification') - # update tx + # reset the transaction tx.write({'state': 'draft', 'acquirer_reference': False}) # update notification from alipay should not go through since it has already been set as 'done' @@ -166,37 +178,16 @@ class AlipayTest(PaymentAcquirerCommon): alipay_post_data['trade_status'] = 'TRADE_FINISHED' else: alipay_post_data['trade_status'] = 'TRADE_SUCCESS' - alipay_post_data['sign'] = self.alipay._build_sign(alipay_post_data) - # validate tx - tx.form_feedback(alipay_post_data, 'alipay') - # check tx - self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', 'alipay: notification should not go throught since it has already been validated') + alipay_post_data['sign'] = self.alipay._alipay_build_sign(alipay_post_data) + + self.env['payment.transaction']._handle_feedback_data('alipay', alipay_post_data) + self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', + 'Alipay: notification should not go throught since it has already been validated') # this time it should go through since the transaction is not validated yet tx.write({'state': 'draft', 'acquirer_reference': False}) - tx.form_feedback(alipay_post_data, 'alipay') - self.assertEqual(tx.state, 'done', 'alipay: wrong state after receiving a valid pending notification') - self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', 'alipay: wrong txn_id after receiving a valid pending notification') - - @mute_logger('odoo.addons.payment_alipay.models.payment', 'ValidationError') - def test_30_alipay_bad_configuration(self): - self.alipay.alipay_payment_method = 'express_checkout' - - # should raise error since `express_checkout` must only be used with CNY currency - with self.assertRaises(ValidationError): - # create tx - tx = self.env['payment.transaction'].create({ - 'acquirer_id': self.alipay.id, - 'amount': 4, - 'currency_id': self.currency_euro.id, - 'reference': 'test_ref_2', - 'partner_country_id': self.country_france.id - }) - - tx = self.env['payment.transaction'].create({ - 'acquirer_id': self.alipay.id, - 'amount': 4, - 'currency_id': self.currency_yuan.id, - 'reference': 'test_ref_2', - 'partner_country_id': self.country_france.id - }) + self.env['payment.transaction']._handle_feedback_data('alipay', alipay_post_data) + self.assertEqual(tx.state, 'done', + 'Alipay: wrong state after receiving a valid pending notification') + self.assertEqual(tx.acquirer_reference, '2017112321001003690200384552', + 'Alipay: wrong txn_id after receiving a valid pending notification') diff --git a/addons/payment_alipay/views/payment_alipay_templates.xml b/addons/payment_alipay/views/payment_alipay_templates.xml index 0f672861074..74902bceb8e 100644 --- a/addons/payment_alipay/views/payment_alipay_templates.xml +++ b/addons/payment_alipay/views/payment_alipay_templates.xml @@ -1,8 +1,8 @@ -