diff --git a/addons/payment_razorpay/models/payment_provider.py b/addons/payment_razorpay/models/payment_provider.py index afb5d9a9e69e0..e2913ef40d567 100644 --- a/addons/payment_razorpay/models/payment_provider.py +++ b/addons/payment_razorpay/models/payment_provider.py @@ -6,7 +6,6 @@ import pprint import requests -from werkzeug.urls import url_join from odoo import _, fields, models from odoo.exceptions import ValidationError @@ -50,7 +49,7 @@ def _compute_feature_support_fields(self): 'support_tokenization': True, }) - # === BUSINESS METHODS ===# + # === BUSINESS METHODS - PAYMENT FLOW === # def _get_supported_currencies(self): """ Override of `payment` to return the supported currencies. """ @@ -75,13 +74,30 @@ def _razorpay_make_request(self, endpoint, payload=None, method='POST'): """ self.ensure_one() - url = url_join('https://api.razorpay.com/v1/', endpoint) - auth = (self.razorpay_key_id, self.razorpay_key_secret) + # TODO: Make api_version a kwarg in master. + api_version = self.env.context.get('razorpay_api_version', 'v1') + url = f'https://api.razorpay.com/{api_version}/{endpoint}' + headers = None + if access_token := self._razorpay_get_access_token(): + headers = {'Authorization': f'Bearer {access_token}'} + auth = (self.razorpay_key_id, self.razorpay_key_secret) if self.razorpay_key_id else None try: if method == 'GET': - response = requests.get(url, params=payload, auth=auth, timeout=10) + response = requests.get( + url, + params=payload, + headers=headers, + auth=auth, + timeout=10, + ) else: - response = requests.post(url, json=payload, auth=auth, timeout=10) + response = requests.post( + url, + json=payload, + headers=headers, + auth=auth, + timeout=10, + ) try: response.raise_for_status() except requests.exceptions.HTTPError: @@ -129,3 +145,13 @@ def _get_validation_amount(self): return res return 1.0 + + # === BUSINESS METHODS - OAUTH === # + + def _razorpay_get_public_token(self): # TODO: remove in master + self.ensure_one() + return None + + def _razorpay_get_access_token(self): # TODO: remove in master + self.ensure_one() + return None diff --git a/addons/payment_razorpay/models/payment_transaction.py b/addons/payment_razorpay/models/payment_transaction.py index 15d2bb36323d0..299ccaf9b5351 100644 --- a/addons/payment_razorpay/models/payment_transaction.py +++ b/addons/payment_razorpay/models/payment_transaction.py @@ -41,6 +41,7 @@ def _get_specific_processing_values(self, processing_values): order_id = self._razorpay_create_order(customer_id)['id'] return { 'razorpay_key_id': self.provider_id.razorpay_key_id, + 'razorpay_public_token': self.provider_id._razorpay_get_public_token(), 'razorpay_customer_id': customer_id, 'is_tokenize_request': self.tokenize, 'razorpay_order_id': order_id, diff --git a/addons/payment_razorpay/static/src/js/payment_form.js b/addons/payment_razorpay/static/src/js/payment_form.js index e723d3d68cc9e..ccc833e763b37 100644 --- a/addons/payment_razorpay/static/src/js/payment_form.js +++ b/addons/payment_razorpay/static/src/js/payment_form.js @@ -58,7 +58,7 @@ paymentForm.include({ */ _prepareRazorpayOptions(processingValues) { return Object.assign({}, processingValues, { - 'key': processingValues['razorpay_key_id'], + 'key': processingValues['razorpay_public_token'] || processingValues['razorpay_key_id'], 'customer_id': processingValues['razorpay_customer_id'], 'order_id': processingValues['razorpay_order_id'], 'description': processingValues['reference'], diff --git a/addons/payment_razorpay_oauth/__init__.py b/addons/payment_razorpay_oauth/__init__.py new file mode 100644 index 0000000000000..796c4d19bd6e9 --- /dev/null +++ b/addons/payment_razorpay_oauth/__init__.py @@ -0,0 +1,4 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import controllers +from . import models diff --git a/addons/payment_razorpay_oauth/__manifest__.py b/addons/payment_razorpay_oauth/__manifest__.py new file mode 100644 index 0000000000000..08043b250e697 --- /dev/null +++ b/addons/payment_razorpay_oauth/__manifest__.py @@ -0,0 +1,16 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +{ + 'name': "Razorpay OAuth Integration", + 'category': 'Accounting/Payment Providers', + 'sequence': 350, + 'summary': "Easy Razorpay Onboarding With Oauth.", + 'icon': '/payment_razorpay/static/description/icon.png', + 'depends': ['payment_razorpay'], + 'data': [ + 'views/payment_provider_views.xml', + 'views/razorpay_templates.xml', + ], + 'auto_install': True, + 'license': 'LGPL-3', +} diff --git a/addons/payment_razorpay_oauth/const.py b/addons/payment_razorpay_oauth/const.py new file mode 100644 index 0000000000000..2009ce7b68aff --- /dev/null +++ b/addons/payment_razorpay_oauth/const.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +OAUTH_URL = 'https://razorpay.api.odoo.com/api/razorpay/1' diff --git a/addons/payment_razorpay_oauth/controllers/__init__.py b/addons/payment_razorpay_oauth/controllers/__init__.py new file mode 100644 index 0000000000000..37a18c689ea16 --- /dev/null +++ b/addons/payment_razorpay_oauth/controllers/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import onboarding diff --git a/addons/payment_razorpay_oauth/controllers/onboarding.py b/addons/payment_razorpay_oauth/controllers/onboarding.py new file mode 100644 index 0000000000000..fd5e75b8a3617 --- /dev/null +++ b/addons/payment_razorpay_oauth/controllers/onboarding.py @@ -0,0 +1,85 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint +from datetime import timedelta +from urllib.parse import urlencode + +from werkzeug.exceptions import Forbidden + +from odoo import _, fields +from odoo.exceptions import ValidationError +from odoo.http import Controller, request, route + + +_logger = logging.getLogger(__name__) + + +class RazorpayController(Controller): + + OAUTH_RETURN_URL = '/payment/razorpay/oauth/return' + + @route(OAUTH_RETURN_URL, type='http', auth='user', methods=['GET'], website=True) + def razorpay_return_from_authorization(self, **data): + """ Exchange the authorization code for an access token and redirect to the provider form. + + :param dict data: The authorization code received from Razorpay, in addition to the provided + provider id and CSRF token that were sent back by the proxy. + :raise Forbidden: If the received CSRF token cannot be verified. + :raise ValidationError: If the provider id does not match any Razorpay provider. + :return: Redirect to the payment provider form. + """ + _logger.info("Returning from authorization with data:\n%s", pprint.pformat(data)) + + # Retrieve the Razorpay data and Odoo metadata from the redirect data. + provider_id = int(data['provider_id']) + authorization_code = data['authorization_code'] + csrf_token = data['csrf_token'] + provider_sudo = request.env['payment.provider'].sudo().browse(provider_id).exists() + if not provider_sudo or provider_sudo.code != 'razorpay': + raise ValidationError(_("Could not find Razorpay provider with id %s", provider_sudo)) + + # Verify the CSRF token. + if not request.validate_csrf(csrf_token): + _logger.warning("CSRF token verification failed.") + raise Forbidden() + + # Request and set the OAuth tokens on the provider. + action = request.env.ref('payment.action_payment_provider') + url_params = { + 'model': provider_sudo._name, + 'id': provider_sudo.id, + 'action': action.id, + 'view_type': 'form', + } + redirect_url = f'/web#{urlencode(url_params)}' # TODO: change to /odoo in saas-17.2! + try: + response_content = provider_sudo._razorpay_make_proxy_request( + '/get_access_token', payload={'authorization_code': authorization_code} + ) + except ValidationError as e: + return request.render( + 'payment_razorpay_oauth.authorization_error', + qcontext={'error_message': str(e), 'provider_url': redirect_url}, + ) + expires_in = fields.Datetime.now() + timedelta(seconds=int(response_content['expires_in'])) + provider_sudo.write({ + # Reset the classical API key fields. + 'razorpay_key_id': None, + 'razorpay_key_secret': None, + 'razorpay_webhook_secret': None, + # Set the new OAuth fields. + 'razorpay_account_id': response_content['razorpay_account_id'], + 'razorpay_public_token': response_content['public_token'], + 'razorpay_refresh_token': response_content['refresh_token'], + 'razorpay_access_token': response_content['access_token'], + 'razorpay_access_token_expiry': expires_in, + # Enable the provider. + 'state': 'enabled', + 'is_published': True, + }) + try: + provider_sudo.action_razorpay_create_webhook() + except ValidationError as error: + _logger.warning(error) + return request.redirect(redirect_url) diff --git a/addons/payment_razorpay_oauth/data/neutralize.sql b/addons/payment_razorpay_oauth/data/neutralize.sql new file mode 100644 index 0000000000000..89bbfb2570213 --- /dev/null +++ b/addons/payment_razorpay_oauth/data/neutralize.sql @@ -0,0 +1,7 @@ +-- disable razorpay payment provider +UPDATE payment_provider + SET razorpay_public_token = NULL, + razorpay_refresh_token = NULL, + razorpay_access_token = NULL, + razorpay_access_token_expiry = NULL, + razorpay_account_id = NULL; diff --git a/addons/payment_razorpay_oauth/i18n/payment_razorpay_oauth.pot b/addons/payment_razorpay_oauth/i18n/payment_razorpay_oauth.pot new file mode 100644 index 0000000000000..ea2d4aedca462 --- /dev/null +++ b/addons/payment_razorpay_oauth/i18n/payment_razorpay_oauth.pot @@ -0,0 +1,168 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * payment_razorpay_oauth +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 17.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-11-13 15:58+0000\n" +"PO-Revision-Date: 2024-11-13 15:58+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.payment_provider_form_razorpay_oauth +msgid "Account ID" +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.authorization_error +msgid "An error occurred" +msgstr "" + +#. module: payment_razorpay_oauth +#. odoo-python +#: code:addons/payment_razorpay_oauth/models/payment_provider.py:0 +#, python-format +msgid "An error occurred when communicating with the proxy." +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.authorization_error +msgid "An error occurred while linking your Razorpay account with Odoo." +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.payment_provider_form_razorpay_oauth +msgid "Are you sure you want to disconnect?" +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.authorization_error +msgid "Back to the Razorpay provider" +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.payment_provider_form_razorpay_oauth +msgid "Connect" +msgstr "" + +#. module: payment_razorpay_oauth +#. odoo-python +#: code:addons/payment_razorpay_oauth/models/payment_provider.py:0 +#, python-format +msgid "Could not establish the connection." +msgstr "" + +#. module: payment_razorpay_oauth +#. odoo-python +#: code:addons/payment_razorpay_oauth/controllers/onboarding.py:0 +#, python-format +msgid "Could not find Razorpay provider with id %s" +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.payment_provider_form_razorpay_oauth +msgid "Generate your webhook" +msgstr "" + +#. module: payment_razorpay_oauth +#. odoo-python +#: code:addons/payment_razorpay_oauth/models/payment_provider.py:0 +#, python-format +msgid "Other Payment Providers" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model,name:payment_razorpay_oauth.model_payment_provider +msgid "Payment Provider" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,field_description:payment_razorpay_oauth.field_payment_provider__razorpay_access_token +msgid "Razorpay Access Token" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,field_description:payment_razorpay_oauth.field_payment_provider__razorpay_access_token_expiry +msgid "Razorpay Access Token Expiry" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,field_description:payment_razorpay_oauth.field_payment_provider__razorpay_account_id +msgid "Razorpay Account ID" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,field_description:payment_razorpay_oauth.field_payment_provider__razorpay_key_id +msgid "Razorpay Key Id" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,field_description:payment_razorpay_oauth.field_payment_provider__razorpay_key_secret +msgid "Razorpay Key Secret" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,field_description:payment_razorpay_oauth.field_payment_provider__razorpay_public_token +msgid "Razorpay Public Token" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,field_description:payment_razorpay_oauth.field_payment_provider__razorpay_refresh_token +msgid "Razorpay Refresh Token" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,field_description:payment_razorpay_oauth.field_payment_provider__razorpay_webhook_secret +msgid "Razorpay Webhook Secret" +msgstr "" + +#. module: payment_razorpay_oauth +#. odoo-python +#: code:addons/payment_razorpay_oauth/models/payment_provider.py:0 +#, python-format +msgid "" +"Razorpay is not available in your country; please use another payment " +"provider." +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.payment_provider_form_razorpay_oauth +msgid "Reset Your Razorpay Account" +msgstr "" + +#. module: payment_razorpay_oauth +#: model:ir.model.fields,help:payment_razorpay_oauth.field_payment_provider__razorpay_key_id +msgid "The key solely used to identify the account with Razorpay." +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.payment_provider_form_razorpay_oauth +msgid "This provider is linked with your Razorpay account." +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.payment_provider_form_razorpay_oauth +msgid "Webhook Secret" +msgstr "" + +#. module: payment_razorpay_oauth +#: model_terms:ir.ui.view,arch_db:payment_razorpay_oauth.payment_provider_form_razorpay_oauth +msgid "" +"You are currently connected to Razorpay through the credentials method, which is\n" +" deprecated. Click the \"Connect\" button below to use the recommended OAuth\n" +" method." +msgstr "" + +#. module: payment_razorpay_oauth +#. odoo-python +#: code:addons/payment_razorpay_oauth/models/payment_provider.py:0 +#, python-format +msgid "Your Razorpay webhook was successfully set up!" +msgstr "" diff --git a/addons/payment_razorpay_oauth/models/__init__.py b/addons/payment_razorpay_oauth/models/__init__.py new file mode 100644 index 0000000000000..6d10b7b4d4bcc --- /dev/null +++ b/addons/payment_razorpay_oauth/models/__init__.py @@ -0,0 +1,3 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import payment_provider diff --git a/addons/payment_razorpay_oauth/models/payment_provider.py b/addons/payment_razorpay_oauth/models/payment_provider.py new file mode 100644 index 0000000000000..3ccd0e3b30c86 --- /dev/null +++ b/addons/payment_razorpay_oauth/models/payment_provider.py @@ -0,0 +1,206 @@ +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging +import pprint +import uuid +from datetime import timedelta +from urllib.parse import urlencode + +import requests + +from odoo import _, fields, models +from odoo.exceptions import RedirectWarning, ValidationError +from odoo.http import request + +from odoo.addons.payment_razorpay import const +from odoo.addons.payment_razorpay_oauth import const as oauth_const +from odoo.addons.payment_razorpay_oauth.controllers.onboarding import RazorpayController + + +_logger = logging.getLogger(__name__) + + +class PaymentProvider(models.Model): + _inherit = 'payment.provider' + + razorpay_key_id = fields.Char(required_if_provider=False) + razorpay_key_secret = fields.Char(required_if_provider=False) + razorpay_webhook_secret = fields.Char(required_if_provider=False) + + # OAuth fields + razorpay_account_id = fields.Char(string="Razorpay Account ID", groups='base.group_system') + razorpay_refresh_token = fields.Char( + string="Razorpay Refresh Token", groups='base.group_system' + ) + razorpay_public_token = fields.Char(string="Razorpay Public Token", groups='base.group_system') + razorpay_access_token = fields.Char(string="Razorpay Access Token", groups='base.group_system') + razorpay_access_token_expiry = fields.Datetime( + string="Razorpay Access Token Expiry", groups='base.group_system' + ) + + # === ACTION METHODS ===# + + def action_razorpay_redirect_to_oauth_url(self): + """ Redirect to the Razorpay OAuth URL. + + Note: `self.ensure_one()` + + :return: An URL action to redirect to the Razorpay OAuth URL. + :rtype: dict + """ + self.ensure_one() + + if self.company_id.currency_id.name not in const.SUPPORTED_CURRENCIES: + raise RedirectWarning( + _( + "Razorpay is not available in your country; please use another payment" + " provider." + ), + self.env.ref('payment.action_payment_provider').id, + _("Other Payment Providers"), + ) + + params = { + 'return_url': f'{self.get_base_url()}{RazorpayController.OAUTH_RETURN_URL}', + 'provider_id': self.id, + 'csrf_token': request.csrf_token(), + } + authorization_url = f'{oauth_const.OAUTH_URL}/authorize?{urlencode(params)}' + return { + 'type': 'ir.actions.act_url', + 'url': authorization_url, + 'target': 'self', + } + + def action_razorpay_reset_oauth_account(self): + """ Reset the Razorpay OAuth account. + + Note: self.ensure_one() + + :return: None + """ + self.ensure_one() + + return self.write({ + 'razorpay_account_id': None, + 'razorpay_public_token': None, + 'razorpay_refresh_token': None, + 'razorpay_access_token': None, + 'razorpay_access_token_expiry': None, + 'state': 'disabled', + 'is_published': False, + }) + + def action_razorpay_create_webhook(self): + """ Create a webhook and display a toast notification. + + Note: `self.ensure_one()` + + :return: The feedback notification. + :rtype: dict + """ + self.ensure_one() + + webhook_secret = uuid.uuid4().hex # Generate a random webhook secret. + payload = { + 'url': f'{self.get_base_url()}/payment/razorpay/webhook', + 'alert_email': self.env.user.partner_id.email, + 'secret': webhook_secret, + 'events': const.HANDLED_WEBHOOK_EVENTS, + } + _logger.info( + "Sending '/accounts/%(account_id)s/webhooks' request:\n%(payload)s", + {'account_id': self.razorpay_account_id, 'payload': pprint.pformat(payload)}, + ) + webhook_data = self.with_context(razorpay_api_version='v2')._razorpay_make_request( + f'accounts/{self.razorpay_account_id}/webhooks', payload=payload + ) + _logger.info( + "Response of '/accounts/%(account_id)s/webhooks' request:\n%(response)s", + {'account_id': self.razorpay_account_id, 'response': pprint.pformat(webhook_data)}, + ) + self.razorpay_webhook_secret = webhook_secret + + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'type': 'success', + 'message': _("Your Razorpay webhook was successfully set up!"), + 'next': {'type': 'ir.actions.client', 'tag': 'soft_reload'}, + }, + } + + # === BUSINESS METHODS - OAUTH === # + + def _razorpay_make_proxy_request(self, endpoint, payload=None): + """ Make a request to the Razorpay proxy at the specified endpoint. + + :param str endpoint: The proxy endpoint to be reached by the request; prefixed with '/'. + :param dict payload: The payload of the request. + :return The JSON-formatted content of the response. + :rtype: dict + :raise ValidationError: If an HTTP error occurs. + """ + proxy_payload = { + 'jsonrpc': '2.0', + 'id': uuid.uuid4().hex, + 'method': 'call', + 'params': payload, + } + url = f'{oauth_const.OAUTH_URL}{endpoint}' + try: + response = requests.post(url, json=proxy_payload, timeout=10) + response.raise_for_status() + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout): + _logger.exception("Unable to reach endpoint at %s", url) + raise ValidationError("Razorpay Proxy: " + _("Could not establish the connection.")) + except requests.exceptions.HTTPError: + _logger.exception( + "Invalid API request at %s with data %s", url, pprint.pformat(payload) + ) + raise ValidationError( + "Razorpay Proxy: " + _("An error occurred when communicating with the proxy.") + ) + + # Razorpay proxy endpoints always respond with HTTP 200 as they implement JSON-RPC 2.0. + response_content = response.json() + if response_content.get('error'): # An exception was raised on the proxy side. + error_message = response_content['error']['data']['message'] + _logger.exception("Request forwarded with error: %s", error_message) + raise ValidationError(f"Razorpay Proxy: {error_message}") + + return response_content['result'] + + def _razorpay_get_public_token(self): + self.ensure_one() + + return self.razorpay_public_token + + def _razorpay_get_access_token(self): + self.ensure_one() + + if self.razorpay_access_token and self.razorpay_access_token_expiry < fields.Datetime.now(): + self._razorpay_refresh_access_token() + return self.razorpay_access_token + + def _razorpay_refresh_access_token(self): + """ Refresh the access token. + + Note: `self.ensure_one()` + + :return: dict + """ + self.ensure_one() + + response_content = self._razorpay_make_proxy_request( + '/refresh_access_token', payload={'refresh_token': self.razorpay_refresh_token} + ) + if response_content.get('access_token'): + expiry = fields.Datetime.now() + timedelta(seconds=int(response_content['expires_in'])) + self.write({ + 'razorpay_public_token': response_content['public_token'], + 'razorpay_refresh_token': response_content['refresh_token'], + 'razorpay_access_token': response_content['access_token'], + 'razorpay_access_token_expiry': expiry, + }) diff --git a/addons/payment_razorpay_oauth/views/payment_provider_views.xml b/addons/payment_razorpay_oauth/views/payment_provider_views.xml new file mode 100644 index 0000000000000..62db67f535839 --- /dev/null +++ b/addons/payment_razorpay_oauth/views/payment_provider_views.xml @@ -0,0 +1,78 @@ + + + + + Razorpay Provider Oauth Form + payment.provider + + + +
+ This provider is linked with your Razorpay account. +
+
+ + + + False + razorpay_account_id + base.group_no_one + + + razorpay_key_id + razorpay_account_id + base.group_no_one + + + + + +
+
+
+
+
+ +
diff --git a/addons/payment_razorpay_oauth/views/razorpay_templates.xml b/addons/payment_razorpay_oauth/views/razorpay_templates.xml new file mode 100644 index 0000000000000..214bc2f0ac824 --- /dev/null +++ b/addons/payment_razorpay_oauth/views/razorpay_templates.xml @@ -0,0 +1,23 @@ + + + + + +