From b14ce986ce6aced638c3a4ba483f2a1d86936eeb Mon Sep 17 00:00:00 2001 From: Kevin Baptiste Date: Fri, 26 Mar 2021 18:31:49 +0100 Subject: [PATCH] [REF] payment_payumoney: migrate PayU money to the new payment API See the merge commit for more details. task-2333039 --- addons/payment_payumoney/__init__.py | 11 +- addons/payment_payumoney/__manifest__.py | 10 +- .../payment_payumoney/controllers/__init__.py | 1 - addons/payment_payumoney/controllers/main.py | 19 ++- .../data/payment_acquirer_data.xml | 20 +-- addons/payment_payumoney/models/__init__.py | 4 +- addons/payment_payumoney/models/payment.py | 137 ------------------ .../models/payment_acquirer.py | 51 +++++++ .../models/payment_transaction.py | 114 +++++++++++++++ addons/payment_payumoney/tests/__init__.py | 4 + addons/payment_payumoney/tests/common.py | 18 +++ .../payment_payumoney/tests/test_payumoney.py | 56 +++++++ .../views/payment_payumoney_templates.xml | 32 ++-- .../payment_payumoney/views/payment_views.xml | 8 +- 14 files changed, 297 insertions(+), 188 deletions(-) delete mode 100644 addons/payment_payumoney/models/payment.py create mode 100644 addons/payment_payumoney/models/payment_acquirer.py create mode 100644 addons/payment_payumoney/models/payment_transaction.py create mode 100644 addons/payment_payumoney/tests/__init__.py create mode 100644 addons/payment_payumoney/tests/common.py create mode 100644 addons/payment_payumoney/tests/test_payumoney.py diff --git a/addons/payment_payumoney/__init__.py b/addons/payment_payumoney/__init__.py index 6cf313c4b39..472a51c2c4c 100644 --- a/addons/payment_payumoney/__init__.py +++ b/addons/payment_payumoney/__init__.py @@ -1,10 +1,11 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import models from . import controllers -from odoo.addons.payment.models.payment_acquirer import create_missing_journal_for_acquirers -from odoo.addons.payment import reset_payment_provider +from . import models + +from odoo.addons.payment import reset_payment_acquirer +from odoo.addons.payment.models.payment_acquirer import create_missing_journals # post-init hook + def uninstall_hook(cr, registry): - reset_payment_provider(cr, registry, 'payumoney') + reset_payment_acquirer(cr, registry, 'payumoney') diff --git a/addons/payment_payumoney/__manifest__.py b/addons/payment_payumoney/__manifest__.py index 3989daea519..e0d4826eea2 100644 --- a/addons/payment_payumoney/__manifest__.py +++ b/addons/payment_payumoney/__manifest__.py @@ -1,16 +1,16 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. { 'name': 'PayuMoney Payment Acquirer', + 'version': '2.0', 'category': 'Accounting/Payment Acquirers', 'sequence': 375, 'summary': 'Payment Acquirer: PayuMoney Implementation', 'description': """ - PayuMoney Payment Acquirer for India. +PayuMoney Payment Acquirer for India. - PayUmoney payment gateway supports only INR currency. - """, +PayUmoney payment gateway supports only INR currency. +""", 'depends': ['payment'], 'data': [ 'views/payment_views.xml', @@ -18,6 +18,6 @@ '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_payumoney/controllers/__init__.py b/addons/payment_payumoney/controllers/__init__.py index 5d4b25db9c0..80ee4da1c5e 100644 --- a/addons/payment_payumoney/controllers/__init__.py +++ b/addons/payment_payumoney/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_payumoney/controllers/main.py b/addons/payment_payumoney/controllers/main.py index a565afddeee..7d7d8ec6c8c 100644 --- a/addons/payment_payumoney/controllers/main.py +++ b/addons/payment_payumoney/controllers/main.py @@ -1,8 +1,8 @@ -# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging import pprint + import werkzeug from odoo import http @@ -11,12 +11,11 @@ from odoo.http import request _logger = logging.getLogger(__name__) -class PayuMoneyController(http.Controller): - @http.route(['/payment/payumoney/return', '/payment/payumoney/cancel', '/payment/payumoney/error'], type='http', auth='public', csrf=False) - def payu_return(self, **post): - """ PayUmoney.""" - _logger.info( - 'PayUmoney: entering form_feedback with post data %s', pprint.pformat(post)) - if post: - request.env['payment.transaction'].sudo().form_feedback(post, 'payumoney') - return werkzeug.utils.redirect('/payment/process') +class PayUMoneyController(http.Controller): + _return_url = '/payment/payumoney/return' + + @http.route(_return_url, type='http', auth='public', methods=['GET', 'POST'], csrf=False) + def payumoney_return(self, **data): + _logger.info("entering handle_feedback_data with data:\n%s", pprint.pformat(data)) + request.env['payment.transaction'].sudo()._handle_feedback_data('payumoney', data) + return werkzeug.utils.redirect('/payment/status') diff --git a/addons/payment_payumoney/data/payment_acquirer_data.xml b/addons/payment_payumoney/data/payment_acquirer_data.xml index b70054659f7..3e204767c4d 100644 --- a/addons/payment_payumoney/data/payment_acquirer_data.xml +++ b/addons/payment_payumoney/data/payment_acquirer_data.xml @@ -1,12 +1,12 @@ - - - - PayUmoney - - payumoney - - - - + + + + payumoney + + False + False + False + + diff --git a/addons/payment_payumoney/models/__init__.py b/addons/payment_payumoney/models/__init__.py index 2ec5b9cd2f6..8e04237423a 100644 --- a/addons/payment_payumoney/models/__init__.py +++ b/addons/payment_payumoney/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_payumoney/models/payment.py b/addons/payment_payumoney/models/payment.py deleted file mode 100644 index efba967748b..00000000000 --- a/addons/payment_payumoney/models/payment.py +++ /dev/null @@ -1,137 +0,0 @@ -# -*- coding: utf-8 -*- -# Part of Odoo. See LICENSE file for full copyright and licensing details. - -import hashlib - -from werkzeug import urls - -from odoo import api, fields, models, _ -from odoo.addons.payment.models.payment_acquirer import ValidationError -from odoo.tools.float_utils import float_compare - -import logging - -_logger = logging.getLogger(__name__) - - -class PaymentAcquirerPayumoney(models.Model): - _inherit = 'payment.acquirer' - - provider = fields.Selection(selection_add=[ - ('payumoney', 'PayUmoney') - ], ondelete={'payumoney': 'set default'}) - payumoney_merchant_key = fields.Char(string='Merchant Key', required_if_provider='payumoney', groups='base.group_user') - payumoney_merchant_salt = fields.Char(string='Merchant Salt', required_if_provider='payumoney', groups='base.group_user') - - def _get_payumoney_urls(self, environment): - """ PayUmoney URLs""" - if environment == 'prod': - return {'payumoney_form_url': 'https://secure.payu.in/_payment'} - else: - return {'payumoney_form_url': 'https://sandboxsecure.payu.in/_payment'} - - def _payumoney_generate_sign(self, inout, values): - """ Generate the shasign for incoming or outgoing communications. - :param self: the self browse record. It should have a shakey in shakey out - :param string inout: 'in' (odoo contacting payumoney) or 'out' (payumoney - contacting odoo). - :param dict values: transaction values - - :return string: shasign - """ - if inout not in ('in', 'out'): - raise Exception("Type must be 'in' or 'out'") - - if inout == 'in': - keys = "key|txnid|amount|productinfo|firstname|email|udf1|||||||||".split('|') - sign = ''.join('%s|' % (values.get(k) or '') for k in keys) - sign += self.payumoney_merchant_salt or '' - else: - keys = "|status||||||||||udf1|email|firstname|productinfo|amount|txnid".split('|') - sign = ''.join('%s|' % (values.get(k) or '') for k in keys) - sign = self.payumoney_merchant_salt + sign + self.payumoney_merchant_key - - shasign = hashlib.sha512(sign.encode('utf-8')).hexdigest() - return shasign - - def payumoney_form_generate_values(self, values): - self.ensure_one() - base_url = self.get_base_url() - payumoney_values = dict(values, - key=self.payumoney_merchant_key, - txnid=values['reference'], - amount=values['amount'], - productinfo=values['reference'], - firstname=values.get('partner_name'), - email=values.get('partner_email'), - phone=values.get('partner_phone'), - service_provider='payu_paisa', - surl=urls.url_join(base_url, '/payment/payumoney/return'), - furl=urls.url_join(base_url, '/payment/payumoney/error'), - curl=urls.url_join(base_url, '/payment/payumoney/cancel') - ) - - payumoney_values['udf1'] = payumoney_values.pop('return_url', '/') - payumoney_values['hash'] = self._payumoney_generate_sign('in', payumoney_values) - return payumoney_values - - def payumoney_get_form_action_url(self): - self.ensure_one() - environment = 'prod' if self.state == 'enabled' else 'test' - return self._get_payumoney_urls(environment)['payumoney_form_url'] - - -class PaymentTransactionPayumoney(models.Model): - _inherit = 'payment.transaction' - - @api.model - def _payumoney_form_get_tx_from_data(self, data): - """ Given a data dict coming from payumoney, verify it and find the related - transaction record. """ - reference = data.get('txnid') - pay_id = data.get('mihpayid') - shasign = data.get('hash') - if not reference or not pay_id or not shasign: - raise ValidationError(_('PayUmoney: received data with missing reference (%s) or pay_id (%s) or shasign (%s)') % (reference, pay_id, shasign)) - - transaction = self.search([('reference', '=', reference)]) - - if not transaction: - error_msg = (_('PayUmoney: received data for reference %s; no order found') % (reference)) - raise ValidationError(error_msg) - elif len(transaction) > 1: - error_msg = (_('PayUmoney: received data for reference %s; multiple orders found') % (reference)) - raise ValidationError(error_msg) - - #verify shasign - shasign_check = transaction.acquirer_id._payumoney_generate_sign('out', data) - if shasign_check.upper() != shasign.upper(): - raise ValidationError(_('PayUmoney: invalid shasign, received %s, computed %s, for data %s') % (shasign, shasign_check, data)) - return transaction - - def _payumoney_form_get_invalid_parameters(self, data): - invalid_parameters = [] - - if self.acquirer_reference and data.get('mihpayid') != self.acquirer_reference: - invalid_parameters.append( - ('Transaction Id', data.get('mihpayid'), self.acquirer_reference)) - #check what is buyed - if float_compare(float(data.get('amount', '0.0')), self.amount, 2) != 0: - invalid_parameters.append( - ('Amount', data.get('amount'), '%.2f' % self.amount)) - - return invalid_parameters - - def _payumoney_form_validate(self, data): - status = data.get('status') - result = self.write({ - 'acquirer_reference': data.get('payuMoneyId'), - 'date': fields.Datetime.now(), - }) - if status == 'success': - self._set_transaction_done() - elif status != 'pending': - self._set_transaction_cancel() - else: - self._set_transaction_pending() - return result diff --git a/addons/payment_payumoney/models/payment_acquirer.py b/addons/payment_payumoney/models/payment_acquirer.py new file mode 100644 index 00000000000..282b45f052b --- /dev/null +++ b/addons/payment_payumoney/models/payment_acquirer.py @@ -0,0 +1,51 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import hashlib + +from odoo import api, fields, models + + +class PaymentAcquirer(models.Model): + _inherit = 'payment.acquirer' + + provider = fields.Selection( + selection_add=[('payumoney', "PayUmoney")], ondelete={'payumoney': 'set default'}) + payumoney_merchant_key = fields.Char( + string="Merchant Key", help="The key solely used to identify the account with PayU money", + required_if_provider='payumoney') + payumoney_merchant_salt = fields.Char( + string="Merchant Salt", required_if_provider='payumoney', groups='base.group_system') + + @api.model + def _get_compatible_acquirers(self, *args, currency_id=None, **kwargs): + """ Override of payment to unlist PayUmoney acquirers when the currency is not INR. """ + 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 != 'INR': + acquirers = acquirers.filtered(lambda a: a.provider != 'payumoney') + + return acquirers + + def _payumoney_generate_sign(self, values, incoming=True): + """ Generate the shasign for incoming or outgoing communications. + + :param dict values: The values used to generate the signature + :param bool incoming: Whether the signature must be generated for an incoming (PayUmoney to + Odoo) or outgoing (Odoo to PayUMoney) communication. + :return: The shasign + :rtype: str + """ + sign_values = { + **values, + 'key': self.payumoney_merchant_key, + 'salt': self.payumoney_merchant_salt, + } + if incoming: + keys = 'salt|status||||||udf5|udf4|udf3|udf2|udf1|email|firstname|productinfo|amount|' \ + 'txnid|key' + sign = '|'.join(f'{sign_values.get(k) or ""}' for k in keys.split('|')) + else: # outgoing + keys = 'key|txnid|amount|productinfo|firstname|email|udf1|udf2|udf3|udf4|udf5||||||salt' + sign = '|'.join(f'{sign_values.get(k) or ""}' for k in keys.split('|')) + return hashlib.sha512(sign.encode('utf-8')).hexdigest() diff --git a/addons/payment_payumoney/models/payment_transaction.py b/addons/payment_payumoney/models/payment_transaction.py new file mode 100644 index 00000000000..a814fc7b2a8 --- /dev/null +++ b/addons/payment_payumoney/models/payment_transaction.py @@ -0,0 +1,114 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +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_payumoney.controllers.main import PayUMoneyController + + +class PaymentTransaction(models.Model): + _inherit = 'payment.transaction' + + def _get_specific_rendering_values(self, processing_values): + """ Override of payment to return Payumoney-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 != 'payumoney': + return res + + first_name, last_name = payment_utils.split_partner_name(self.partner_id.name) + api_url = 'https://secure.payu.in/_payment' if self.acquirer_id.state == 'enabled' \ + else 'https://sandboxsecure.payu.in/_payment' + payumoney_values = { + 'key': self.acquirer_id.payumoney_merchant_key, + 'txnid': self.reference, + 'amount': self.amount, + 'productinfo': self.reference, + 'firstname': first_name, + 'lastname': last_name, + 'email': self.partner_email, + 'phone': self.partner_phone, + 'return_url': urls.url_join(self.get_base_url(), PayUMoneyController._return_url), + 'api_url': api_url, + } + payumoney_values['hash'] = self.acquirer_id._payumoney_generate_sign( + payumoney_values, incoming=False, + ) + return payumoney_values + + @api.model + def _get_tx_from_feedback_data(self, provider, data): + """ Override of payment to find the transaction based on Payumoney 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 + :raise: ValidationError if the signature can not be verified + """ + tx = super()._get_tx_from_feedback_data(provider, data) + if provider != 'payumoney': + return tx + + reference = data.get('txnid') + shasign = data.get('hash') + if not reference or not shasign: + raise ValidationError( + "PayUmoney: " + _( + "Received data with missing reference (%(ref)s) or shasign (%(sign)s)", + ref=reference, sign=shasign, + ) + ) + + tx = self.search([('reference', '=', reference), ('provider', '=', 'payumoney')]) + if not tx: + raise ValidationError( + "PayUmoney: " + _("No transaction found matching reference %s.", reference) + ) + + # Verify shasign + shasign_check = tx.acquirer_id._payumoney_generate_sign(data, incoming=True) + if shasign_check != shasign: + raise ValidationError( + "PayUmoney: " + _( + "Invalid shasign: received %(sign)s, computed %(computed)s.", + sign=shasign, computed=shasign_check + ) + ) + + return tx + + def _process_feedback_data(self, data): + """ Override of payment to process the transaction based on Payumoney data. + + Note: self.ensure_one() + + :param dict data: The feedback data sent by the provider + :return: None + """ + super()._process_feedback_data(data) + if self.provider != 'payumoney': + return + + status = data.get('status') + self.acquirer_reference = data.get('payuMoneyId') + + if status == 'success': + self._set_done() + else: # 'failure' + # See https://www.payumoney.com/pdf/PayUMoney-Technical-Integration-Document.pdf + error_code = data.get('Error') + self._set_error( + "PayUmoney: " + _("The payment encountered an error with code %s", error_code) + ) diff --git a/addons/payment_payumoney/tests/__init__.py b/addons/payment_payumoney/tests/__init__.py new file mode 100644 index 00000000000..709b009a6c0 --- /dev/null +++ b/addons/payment_payumoney/tests/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import common +from . import test_payumoney diff --git a/addons/payment_payumoney/tests/common.py b/addons/payment_payumoney/tests/common.py new file mode 100644 index 00000000000..0bb2ab700e6 --- /dev/null +++ b/addons/payment_payumoney/tests/common.py @@ -0,0 +1,18 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo.addons.payment.tests.common import PaymentCommon + + +class PayumoneyCommon(PaymentCommon): + + @classmethod + def setUpClass(cls): + super().setUpClass() + + cls.payumoney = cls._prepare_acquirer('payumoney', update_values={ + 'payumoney_merchant_key': 'dummy', + 'payumoney_merchant_salt': 'dummy', + }) + + # Override default values + cls.acquirer = cls.payumoney + cls.currency = cls._prepare_currency('INR') diff --git a/addons/payment_payumoney/tests/test_payumoney.py b/addons/payment_payumoney/tests/test_payumoney.py new file mode 100644 index 00000000000..7daab73d5a9 --- /dev/null +++ b/addons/payment_payumoney/tests/test_payumoney.py @@ -0,0 +1,56 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo.tests import tagged +from odoo.tools import mute_logger + +from .common import PayumoneyCommon +from ..controllers.main import PayUMoneyController +from odoo.addons.payment import utils as payment_utils + + +@tagged('post_install', '-at_install') +class PayumoneyTest(PayumoneyCommon): + + def test_compatible_acquirers(self): + acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + partner_id=self.partner.id, + company_id=self.company.id, + currency_id=self.currency.id, + ) + self.assertIn(self.payumoney, acquirers) + + acquirers = self.env['payment.acquirer']._get_compatible_acquirers( + partner_id=self.partner.id, + company_id=self.company.id, + currency_id=self.currency_euro.id, + ) + self.assertNotIn(self.payumoney, acquirers) + + def test_redirect_form_values(self): + tx = self.create_transaction(flow='redirect') + with mute_logger('odoo.addons.payment.models.payment_transaction'): + processing_values = tx._get_processing_values() + + form_info = self._extract_values_from_html_form(processing_values['redirect_form_html']) + first_name, last_name = payment_utils.split_partner_name(self.partner.name) + return_url = self._build_url(PayUMoneyController._return_url) + expected_values = { + 'key': self.payumoney.payumoney_merchant_key, + 'txnid': self.reference, + 'amount': str(self.amount), + 'productinfo': self.reference, + 'firstname': first_name, + 'lastname': last_name, + 'email': self.partner.email, + 'phone': self.partner.phone, + 'surl': return_url, + 'furl': return_url, + 'service_provider': 'payu_paisa', + } + expected_values['hash'] = self.payumoney._payumoney_generate_sign( + expected_values, incoming=False, + ) + self.assertEqual(form_info['action'], + 'https://sandboxsecure.payu.in/_payment') + self.assertDictEqual(form_info['inputs'], expected_values, + "PayUMoney: invalid inputs specified in the redirect form.") diff --git a/addons/payment_payumoney/views/payment_payumoney_templates.xml b/addons/payment_payumoney/views/payment_payumoney_templates.xml index 70ec052fd0c..bf8070b768b 100644 --- a/addons/payment_payumoney/views/payment_payumoney_templates.xml +++ b/addons/payment_payumoney/views/payment_payumoney_templates.xml @@ -1,19 +1,21 @@ -