diff --git a/addons/payment/data/payment_acquirer_data.xml b/addons/payment/data/payment_acquirer_data.xml
index 69a926ea093..5c25b146290 100644
--- a/addons/payment/data/payment_acquirer_data.xml
+++ b/addons/payment/data/payment_acquirer_data.xml
@@ -349,6 +349,23 @@
])]"/>
+
+ Razorpay
+ Credit & Debit Card, UPI (Powered by Razorpay)
+
+
+
+
+
+
SEPA Direct Debit
20
diff --git a/addons/payment/data/payment_icon_data.xml b/addons/payment/data/payment_icon_data.xml
index 9f777e7b424..b4cd2aed0c5 100644
--- a/addons/payment/data/payment_icon_data.xml
+++ b/addons/payment/data/payment_icon_data.xml
@@ -37,6 +37,12 @@
+
+ 65
+ Rupay
+
+
+
70
Apple Pay
diff --git a/addons/payment/static/img/rupay.png b/addons/payment/static/img/rupay.png
new file mode 100644
index 00000000000..7dd3771a493
Binary files /dev/null and b/addons/payment/static/img/rupay.png differ
diff --git a/addons/payment_razorpay/README.md b/addons/payment_razorpay/README.md
new file mode 100644
index 00000000000..ba0dab1bcaf
--- /dev/null
+++ b/addons/payment_razorpay/README.md
@@ -0,0 +1,36 @@
+# Razorpay
+
+## Implementation details
+
+### Supported features
+
+- Payment with redirection flow
+- Manual capture
+- Partial refunds
+- Several payment methods such as debit/credit cards, netbanking, UPI, and
+ [others](https://razorpay.com/docs/payments/payment-methods/).
+- [Webhook](https://razorpay.com/docs/webhooks).
+
+In addition, Razorpay also allows to implement tokenization but requires passing the card secret for
+each transaction.
+
+### API and gateway
+
+We choose to integrate with
+[Razorpay Hosted Checkout](https://razorpay.com/docs/payments/payment-gateway/web-integration/hosted).
+The other gateways were ruled out. See the task's dev notes for the details on the other gateways.
+
+The version of the API implemented by this module is v1.
+
+## Merge details
+
+The first version of the module was specified in task
+[2800823](https://www.odoo.com/web#id=2800823&model=project.task) and merged with PR
+odoo/odoo#92848 in `saas-15.5`.
+
+## Testing instructions
+
+The partner's phone number must be a valid Indian phone number. Example: +91123456789
+
+See https://razorpay.com/docs/payments/payments/test-card-upi-details/ for the list of test
+payment details.
diff --git a/addons/payment_razorpay/__init__.py b/addons/payment_razorpay/__init__.py
new file mode 100644
index 00000000000..141de1d6b73
--- /dev/null
+++ b/addons/payment_razorpay/__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, 'razorpay')
diff --git a/addons/payment_razorpay/__manifest__.py b/addons/payment_razorpay/__manifest__.py
new file mode 100644
index 00000000000..00c9abcd51b
--- /dev/null
+++ b/addons/payment_razorpay/__manifest__.py
@@ -0,0 +1,19 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': "Payment Acquirer: Razorpay",
+ 'version': '1.0',
+ 'category': 'Accounting/Payment Acquirers',
+ 'sequence': 385,
+ 'summary': "A Indian online payment provider covering 100+ payment methods.",
+ 'depends': ['payment'],
+ 'data': [
+ 'views/payment_views.xml',
+ 'views/payment_razorpay_templates.xml',
+
+ 'data/payment_acquirer_data.xml', # Depends on views/payment_razorpay_templates.xml
+ ],
+ 'application': True,
+ 'uninstall_hook': 'uninstall_hook',
+ 'license': 'LGPL-3',
+}
diff --git a/addons/payment_razorpay/const.py b/addons/payment_razorpay/const.py
new file mode 100644
index 00000000000..2aaf70bb644
--- /dev/null
+++ b/addons/payment_razorpay/const.py
@@ -0,0 +1,32 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+# The currencies supported by Razorpay, in ISO 4217 format. Last updated on May 26, 2021.
+# See https://razorpay.com/docs/payments/payments/international-payments/#supported-currencies.
+SUPPORTED_CURRENCIES = [
+ 'AED', 'ALL', 'AMD', 'ARS', 'AUD', 'AWG', 'BBD', 'BDT', 'BMD', 'BND', 'BOB', 'BSD', 'BWP',
+ 'BZD', 'CAD', 'CHF', 'CNY', 'COP', 'CRC', 'CUP', 'CZK', 'DKK', 'DOP', 'DZD', 'EGP', 'ETB',
+ 'EUR', 'FJD', 'GBP', 'GHS', 'GIP', 'GMD', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', 'HTG', 'HUF',
+ 'IDR', 'ILS', 'INR', 'JMD', 'KES', 'KGS', 'KHR', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD',
+ 'LSL', 'MAD', 'MDL', 'MKD', 'MMK', 'MNT', 'MOP', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'NAD',
+ 'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'PEN', 'PGK', 'PHP', 'PKR', 'QAR', 'RUB', 'SAR', 'SCR',
+ 'SEK', 'SGD', 'SLL', 'SOS', 'SSP', 'SVC', 'SZL', 'THB', 'TTD', 'TZS', 'USD', 'UYU', 'UZS',
+ 'YER', 'ZAR',
+]
+
+# Mapping of transaction states to Razorpay's payment statuses.
+# See https://razorpay.com/docs/payments/payments#payment-life-cycle.
+PAYMENT_STATUS_MAPPING = {
+ 'pending': ('created', 'pending'),
+ 'authorized': ('authorized',),
+ 'done': ('captured', 'refunded', 'processed'), # refunded is included to discard refunded txs.
+ 'error': ('failed',),
+}
+
+# Events that are handled by the webhook.
+HANDLED_WEBHOOK_EVENTS = [
+ 'payment.authorized',
+ 'payment.captured',
+ 'payment.failed',
+ 'refund.failed',
+ 'refund.processed',
+]
diff --git a/addons/payment_razorpay/controllers/__init__.py b/addons/payment_razorpay/controllers/__init__.py
new file mode 100644
index 00000000000..80ee4da1c5e
--- /dev/null
+++ b/addons/payment_razorpay/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_razorpay/controllers/main.py b/addons/payment_razorpay/controllers/main.py
new file mode 100644
index 00000000000..15c14c43b2c
--- /dev/null
+++ b/addons/payment_razorpay/controllers/main.py
@@ -0,0 +1,107 @@
+# 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
+
+from odoo.addons.payment_razorpay.const import HANDLED_WEBHOOK_EVENTS
+
+
+_logger = logging.getLogger(__name__)
+
+
+class RazorpayController(http.Controller):
+ _return_url = '/payment/razorpay/return'
+ _webhook_url = '/payment/razorpay/webhook'
+
+ @http.route(
+ _return_url, type='http', auth='public', methods=['GET', 'POST'], csrf=False,
+ save_session=False
+ )
+ def razorpay_return_from_checkout(self, reference, **data):
+ """ Process the notification data sent by Razorpay after redirection from checkout.
+
+ :param str reference: The transaction reference embedded in the return URL.
+ :param dict data: The notification data.
+ """
+ _logger.info("Handling redirection from Razorpay with data:\n%s", pprint.pformat(data))
+ if all(f'razorpay_{key}' in data for key in ('order_id', 'payment_id', 'signature')):
+ # Check the integrity of the notification.
+ tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
+ 'razorpay', {'description': reference}
+ ) # Use the same key as for webhook notifications' data.
+ self._verify_notification_signature(data, data.get('razorpay_signature'), tx_sudo)
+
+ # Handle the notification data.
+ tx_sudo._handle_notification_data('razorpay', data)
+ else: # The customer cancelled the payment or the payment failed.
+ pass # Don't try to process this case because the payment id was not provided.
+
+ # Redirect the user to the status page.
+ return request.redirect('/payment/status')
+
+ @http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False)
+ def razorpay_webhook(self):
+ """ Process the notification data sent by Razorpay to the webhook.
+
+ :return: An empty string to acknowledge the notification.
+ :rtype: str
+ """
+ data = request.get_json_data()
+ _logger.info("Notification received from Razorpay with data:\n%s", pprint.pformat(data))
+
+ event_type = data['event']
+ if event_type in HANDLED_WEBHOOK_EVENTS:
+ entity_type = 'payment' if 'payment' in event_type else 'refund'
+ try:
+ entity_data = data['payload'].get(entity_type, {}).get('entity', {})
+ entity_data.update(entity_type=entity_type)
+
+ # Check the integrity of the event.
+ received_signature = request.httprequest.headers.get('X-Razorpay-Signature')
+ tx_sudo = request.env['payment.transaction'].sudo()._get_tx_from_notification_data(
+ 'razorpay', entity_data
+ )
+ self._verify_notification_signature(
+ request.httprequest.data, received_signature, tx_sudo, is_redirect=False
+ )
+
+ # Handle the notification data.
+ tx_sudo._handle_notification_data('razorpay', entity_data)
+ except ValidationError: # Acknowledge the notification to avoid getting spammed.
+ _logger.exception("Unable to handle the notification data; skipping to acknowledge")
+ return request.make_json_response('')
+
+ @staticmethod
+ def _verify_notification_signature(
+ notification_data, received_signature, tx_sudo, is_redirect=True
+ ):
+ """ Check that the received signature matches the expected one.
+
+ :param dict|bytes notification_data: The notification data.
+ :param str received_signature: The signature to compare with the expected signature.
+ :param recordset tx_sudo: The sudoed transaction referenced by the notification data, as a
+ `payment.transaction` record
+ :param bool is_redirect: Whether the notification data should be treated as redirect data
+ or as coming from a webhook notification.
+ :return: None
+ :raise :class:`werkzeug.exceptions.Forbidden`: If the signatures don't match.
+ """
+ # Check for the received signature.
+ if not received_signature:
+ _logger.warning("Received notification with missing signature.")
+ raise Forbidden()
+
+ # Compare the received signature with the expected signature.
+ expected_signature = tx_sudo.acquirer_id._razorpay_calculate_signature(
+ notification_data, is_redirect=is_redirect
+ )
+ if not hmac.compare_digest(received_signature, expected_signature):
+ _logger.warning("Received notification with invalid signature.")
+ raise Forbidden()
diff --git a/addons/payment_razorpay/data/payment_acquirer_data.xml b/addons/payment_razorpay/data/payment_acquirer_data.xml
new file mode 100644
index 00000000000..65397ae27a6
--- /dev/null
+++ b/addons/payment_razorpay/data/payment_acquirer_data.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ razorpay
+
+
+
+
+ Razorpay
+ razorpay
+ inbound
+
+
+
diff --git a/addons/payment_razorpay/models/__init__.py b/addons/payment_razorpay/models/__init__.py
new file mode 100644
index 00000000000..28c8703fcc1
--- /dev/null
+++ b/addons/payment_razorpay/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_razorpay/models/account_payment_method.py b/addons/payment_razorpay/models/account_payment_method.py
new file mode 100644
index 00000000000..7e364a300f6
--- /dev/null
+++ b/addons/payment_razorpay/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['razorpay'] = {'mode': 'unique', 'domain': [('type', '=', 'bank')]}
+ return res
diff --git a/addons/payment_razorpay/models/payment_acquirer.py b/addons/payment_razorpay/models/payment_acquirer.py
new file mode 100644
index 00000000000..3b90134a11b
--- /dev/null
+++ b/addons/payment_razorpay/models/payment_acquirer.py
@@ -0,0 +1,137 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import hashlib
+import hmac
+import logging
+import pprint
+
+import requests
+from werkzeug.urls import url_join
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+
+from odoo.addons.payment_razorpay.const import SUPPORTED_CURRENCIES
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PaymentAcquirer(models.Model):
+ _inherit = 'payment.acquirer'
+
+ provider = fields.Selection(
+ selection_add=[('razorpay', "Razorpay")], ondelete={'razorpay': 'set default'}
+ )
+ razorpay_key_id = fields.Char(
+ string="Razorpay Key Id",
+ help="The key solely used to identify the account with Razorpay.",
+ required_if_provider='razorpay',
+ )
+ razorpay_key_secret = fields.Char(
+ string="Razorpay Key Secret",
+ required_if_provider='razorpay',
+ groups='base.group_system',
+ )
+ razorpay_webhook_secret = fields.Char(
+ string="Razorpay Webhook Secret",
+ required_if_provider='razorpay',
+ groups='base.group_system',
+ )
+
+ #=== COMPUTE METHODS ===#
+
+ def _compute_feature_support_fields(self):
+ """ Override of `payment` to enable additional features. """
+ super()._compute_feature_support_fields()
+ self.filtered(lambda acq: acq.provider == 'razorpay').update({
+ 'support_manual_capture': True,
+ 'support_refund': 'partial',
+ })
+
+ # === BUSINESS METHODS ===#
+
+ @api.model
+ def _get_compatible_acquirers(self, *args, currency_id=None, **kwargs):
+ """ Override of `payment` to filter out Razorpay 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 not in SUPPORTED_CURRENCIES:
+ acquirers = acquirers.filtered(lambda a: a.provider != 'razorpay')
+
+ return acquirers
+
+ def _razorpay_make_request(self, endpoint, payload=None, method='POST'):
+ """ Make a request to Razorpay API at the specified endpoint.
+
+ Note: self.ensure_one()
+
+ :param str endpoint: The endpoint to be reached by the request.
+ :param dict payload: The payload of the request.
+ :param str method: The HTTP method of the request.
+ :return The JSON-formatted content of the response.
+ :rtype: dict
+ :raise ValidationError: If an HTTP error occurs.
+ """
+ self.ensure_one()
+
+ url = url_join('https://api.razorpay.com/v1/', endpoint)
+ auth = (self.razorpay_key_id, self.razorpay_key_secret)
+ try:
+ if method == 'GET':
+ response = requests.get(url, params=payload, auth=auth, timeout=10)
+ else:
+ response = requests.post(url, json=payload, auth=auth, timeout=10)
+ try:
+ response.raise_for_status()
+ except requests.exceptions.HTTPError:
+ _logger.exception(
+ "Invalid API request at %s with data:\n%s", url, pprint.pformat(payload),
+ )
+ raise ValidationError("Razorpay: " + _(
+ "The communication with the API failed. Razorpay gave us the following "
+ "information: '%s'", response.json().get('error', {}).get('description')
+ ))
+ except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
+ _logger.exception("Unable to reach endpoint at %s", url)
+ raise ValidationError(
+ "Razorpay: " + _("Could not establish the connection to the API.")
+ )
+ return response.json()
+
+ def _razorpay_calculate_signature(self, data, is_redirect=True):
+ """ Compute the signature for the request's data according to the Razorpay documentation.
+
+ See https://razorpay.com/docs/webhooks/validate-test#validate-webhooks and
+ https://razorpay.com/docs/payments/payment-gateway/web-integration/hosted/build-integration.
+
+ :param dict|bytes data: The data to sign.
+ :param bool is_redirect: Whether the data should be treated as redirect data or as coming
+ from a webhook notification.
+ :return: The calculated signature.
+ :rtype: str
+ """
+ if is_redirect:
+ secret = self.razorpay_key_secret
+ signing_string = f'{data["razorpay_order_id"]}|{data["razorpay_payment_id"]}'
+ return hmac.new(
+ secret.encode(), msg=signing_string.encode(), digestmod=hashlib.sha256
+ ).hexdigest()
+ else: # Notification data.
+ secret = self.razorpay_webhook_secret
+ return hmac.new(secret.encode(), msg=data, digestmod=hashlib.sha256).hexdigest()
+
+ def _get_default_payment_method_id(self):
+ self.ensure_one()
+ if self.provider != 'razorpay':
+ return super()._get_default_payment_method_id()
+ return self.env.ref('payment_razorpay.payment_method_razorpay').id
+
+ def _neutralize(self):
+ super()._neutralize()
+ self._neutralize_fields('razorpay', [
+ 'razorpay_key_id',
+ 'razorpay_key_secret',
+ 'razorpay_webhook_secret',
+ ])
diff --git a/addons/payment_razorpay/models/payment_transaction.py b/addons/payment_razorpay/models/payment_transaction.py
new file mode 100644
index 00000000000..b2feb5b1399
--- /dev/null
+++ b/addons/payment_razorpay/models/payment_transaction.py
@@ -0,0 +1,301 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+import pprint
+
+from werkzeug.urls import url_encode, url_join
+
+from odoo import _, models
+from odoo.exceptions import UserError, ValidationError
+
+from odoo.addons.payment import utils as payment_utils
+from odoo.addons.payment_razorpay.const import PAYMENT_STATUS_MAPPING
+from odoo.addons.payment_razorpay.controllers.main import RazorpayController
+
+
+_logger = logging.getLogger(__name__)
+
+
+class PaymentTransaction(models.Model):
+ _inherit = 'payment.transaction'
+
+ def _get_specific_rendering_values(self, processing_values):
+ """ Override of `payment` to return razorpay-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 rendering values.
+ :rtype: dict
+ """
+ res = super()._get_specific_rendering_values(processing_values)
+ if self.provider != 'razorpay':
+ return res
+
+ # Initiate the payment and retrieve the related order id.
+ payload = self._razorpay_prepare_order_request_payload()
+ _logger.info(
+ "Payload of '/orders' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(payload)
+ )
+ order_data = self.acquirer_id._razorpay_make_request(endpoint='orders', payload=payload)
+ _logger.info(
+ "Response of '/orders' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(order_data)
+ )
+
+ # Initiate the payment
+ converted_amount = payment_utils.to_minor_currency_units(self.amount, self.currency_id)
+ base_url = self.acquirer_id.get_base_url()
+ return_url_params = {'reference': self.reference}
+ rendering_values = {
+ 'key_id': self.acquirer_id.razorpay_key_id,
+ 'name': self.company_id.name,
+ 'description': self.reference,
+ 'company_logo': url_join(base_url, f'web/image/res.company/{self.company_id.id}/logo'),
+ 'order_id': order_data['id'],
+ 'amount': converted_amount,
+ 'currency': self.currency_id.name,
+ 'partner_name': self.partner_name,
+ 'partner_email': self.partner_email,
+ 'partner_phone': self.partner_phone,
+ 'return_url': url_join(
+ base_url, f'{RazorpayController._return_url}?{url_encode(return_url_params)}'
+ ),
+ }
+ return rendering_values
+
+ def _razorpay_prepare_order_request_payload(self):
+ """ Create the payload for the order request based on the transaction values.
+
+ :return: The request payload.
+ :rtype: dict
+ """
+ converted_amount = payment_utils.to_minor_currency_units(self.amount, self.currency_id)
+ payload = {
+ 'amount': converted_amount,
+ 'currency': self.currency_id.name,
+ }
+ if self.acquirer_id.capture_manually: # The related payment must be only authorized.
+ payload.update({
+ 'payment': {
+ 'capture': 'manual',
+ 'capture_options': {
+ 'manual_expiry_period': 7200, # The default value for this required option.
+ 'refund_speed': 'normal', # The default value for this required option.
+ }
+ },
+ })
+ return payload
+
+ def _send_refund_request(self, amount_to_refund=None, create_refund_transaction=True):
+ """ Override of `payment` to send a refund request to Razorpay.
+
+ Note: self.ensure_one()
+
+ :param float amount_to_refund: The amount to refund.
+ :param bool create_refund_transaction: Whether a refund transaction should be created
+ :return: The refund transaction if any
+ :rtype: recordset of `payment.transaction`
+ """
+ if self.provider != 'razorpay':
+ return super()._send_refund_request(
+ amount_to_refund=amount_to_refund,
+ create_refund_transaction=create_refund_transaction,
+ )
+ refund_tx = super()._send_refund_request(
+ amount_to_refund=amount_to_refund, create_refund_transaction=True
+ )
+
+ # Make the refund request to Razorpay.
+ converted_amount = payment_utils.to_minor_currency_units(
+ -refund_tx.amount, refund_tx.currency_id
+ ) # The amount is negative for refund transactions.
+ payload = {
+ 'amount': converted_amount,
+ 'notes': {
+ 'reference': refund_tx.reference, # Allow retrieving the ref. from webhook data.
+ },
+ }
+ _logger.info(
+ "Payload of '/payments//refund' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(payload)
+ )
+ response_content = refund_tx.acquirer_id._razorpay_make_request(
+ f'payments/{self.acquirer_reference}/refund', payload=payload
+ )
+ _logger.info(
+ "Response of '/payments//refund' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(response_content)
+ )
+ response_content.update(entity_type='refund')
+ refund_tx._handle_notification_data('razorpay', response_content)
+
+ return refund_tx
+
+ def _send_capture_request(self):
+ """ Override of `payment` to send a capture request to Razorpay.
+
+ Note: self.ensure_one()
+
+ :return: None
+ """
+ super()._send_capture_request()
+ if self.provider != 'razorpay':
+ return
+
+ converted_amount = payment_utils.to_minor_currency_units(self.amount, self.currency_id)
+ payload = {'amount': converted_amount, 'currency': self.currency_id.name}
+ _logger.info(
+ "Payload of '/payments//capture' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(payload)
+ )
+ response_content = self.acquirer_id._razorpay_make_request(
+ f'payments/{self.acquirer_reference}/capture', payload=payload
+ )
+ _logger.info(
+ "Response of '/payments//capture' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(response_content)
+ )
+
+ # Handle the capture request response.
+ self._handle_notification_data('razorpay', response_content)
+
+ def _send_void_request(self):
+ """ Override of `payment` to explain that it is impossible to void a Razorpay transaction.
+
+ Note: self.ensure_one()
+
+ :return: None
+ """
+ super()._send_void_request()
+ if self.provider != 'razorpay':
+ return
+
+ raise UserError(_("Transactions processed by Razorpay can't be manually voided from Odoo."))
+
+ def _get_tx_from_notification_data(self, provider, notification_data):
+ """ Override of `payment` to find the transaction based on razorpay data.
+
+ :param str provider: The provider of the acquirer that handled the transaction
+ :param dict notification_data: The normalized notification data sent by the provider
+ :return: The transaction if found
+ :rtype: recordset of `payment.transaction`
+ :raise: ValidationError if the data match no transaction
+ """
+ tx = super()._get_tx_from_notification_data(provider, notification_data)
+ if provider != 'razorpay' or len(tx) == 1:
+ return tx
+
+ entity_type = notification_data.get('entity_type', 'payment')
+ if entity_type == 'payment':
+ reference = notification_data.get('description')
+ if not reference:
+ raise ValidationError("Razorpay: " + _("Received data with missing reference."))
+ tx = self.search([('reference', '=', reference), ('provider', '=', 'razorpay')])
+ else: # 'refund'
+ reference = notification_data.get('notes', {}).get('reference')
+ if reference: # The refund was initiated from Odoo.
+ tx = self.search([('reference', '=', reference), ('provider', '=', 'razorpay')])
+ else: # The refund was initiated from Razorpay.
+ # Find the source transaction based on its acquirer reference.
+ source_tx = self.search([
+ ('acquirer_reference', '=', notification_data['payment_id']),
+ ('provider', '=', 'razorpay'),
+ ])
+ if source_tx:
+ # Manually create a refund transaction with a new reference.
+ tx = self._razorpay_create_refund_tx_from_notification_data(
+ source_tx, notification_data
+ )
+ else: # The refund was initiated for an unknown source transaction.
+ pass # Don't do anything with the refund notification.
+ if not tx:
+ raise ValidationError(
+ "Razorpay: " + _("No transaction found matching reference %s.", reference)
+ )
+
+ return tx
+
+ def _razorpay_create_refund_tx_from_notification_data(self, source_tx, notification_data):
+ """ Create a refund transaction based on Razorpay data.
+
+ :param recordset source_tx: The source transaction for which a refund is initiated, as a
+ `payment.transaction` recordset.
+ :param dict notification_data: The notification data sent by the provider.
+ :return: The newly created refund transaction.
+ :rtype: recordset of `payment.transaction`
+ :raise ValidationError: If inconsistent data were received.
+ """
+ refund_acquirer_reference = notification_data.get('id')
+ amount_to_refund = notification_data.get('amount')
+ if not refund_acquirer_reference or not amount_to_refund:
+ raise ValidationError("Razorpay: " + _("Received incomplete refund data."))
+
+ converted_amount = payment_utils.to_major_currency_units(
+ amount_to_refund, source_tx.currency_id
+ )
+ return source_tx._create_refund_transaction(
+ amount_to_refund=converted_amount, acquirer_reference=refund_acquirer_reference
+ )
+
+ def _process_notification_data(self, notification_data):
+ """ Override of `payment` to process the transaction based on Razorpay data.
+
+ Note: self.ensure_one()
+
+ :param dict notification_data: The notification data sent by the provider
+ :return: None
+ """
+ super()._process_notification_data(notification_data)
+ if self.provider != 'razorpay':
+ return
+
+ if 'id' in notification_data: # We have the full entity data (S2S request or webhook).
+ entity_data = notification_data
+ else: # The payment data are not complete (redirect from checkout).
+ # Fetch the full payment data.
+ entity_data = self.acquirer_id._razorpay_make_request(
+ f'payments/{notification_data["razorpay_payment_id"]}', method='GET'
+ )
+ _logger.info(
+ "Response of '/payments' request for transaction with reference %s:\n%s",
+ self.reference, pprint.pformat(entity_data)
+ )
+ entity_id = entity_data.get('id')
+ if not entity_id:
+ raise ValidationError("Razorpay: " + _("Received data with missing entity id."))
+ self.acquirer_reference = entity_id
+
+ entity_status = entity_data.get('status')
+ if not entity_status:
+ raise ValidationError("Razorpay: " + _("Received data with missing status."))
+
+ if entity_status in PAYMENT_STATUS_MAPPING['pending']:
+ self._set_pending()
+ elif entity_status in PAYMENT_STATUS_MAPPING['authorized']:
+ self._set_authorized()
+ elif entity_status in PAYMENT_STATUS_MAPPING['done']:
+ self._set_done()
+
+ # Immediately post-process the transaction if it is a refund, as the post-processing
+ # will not be triggered by a customer browsing the transaction from the portal.
+ if self.operation == 'refund':
+ self.env.ref('payment.cron_post_process_payment_tx')._trigger()
+ elif entity_status in PAYMENT_STATUS_MAPPING['error']:
+ _logger.warning(
+ "The transaction with reference %s underwent an error. Reason: %s",
+ self.reference, entity_data.get('error_description')
+ )
+ self._set_error(
+ _("An error occurred during the processing of your payment. Please try again.")
+ )
+ else: # Classify unsupported payment status as the `error` tx state.
+ _logger.warning(
+ "Received data for transaction with reference %s with invalid payment status: %s",
+ self.reference, entity_status
+ )
+ self._set_error(
+ "Razorpay: " + _("Received data with invalid status: %s", entity_status)
+ )
diff --git a/addons/payment_razorpay/static/description/icon.png b/addons/payment_razorpay/static/description/icon.png
new file mode 100644
index 00000000000..eea0cae79c8
Binary files /dev/null and b/addons/payment_razorpay/static/description/icon.png differ
diff --git a/addons/payment_razorpay/static/description/icon.svg b/addons/payment_razorpay/static/description/icon.svg
new file mode 100644
index 00000000000..5f9dea6e511
--- /dev/null
+++ b/addons/payment_razorpay/static/description/icon.svg
@@ -0,0 +1,27 @@
+
diff --git a/addons/payment_razorpay/tests/__init__.py b/addons/payment_razorpay/tests/__init__.py
new file mode 100644
index 00000000000..bad6afe5061
--- /dev/null
+++ b/addons/payment_razorpay/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_razorpay/tests/common.py b/addons/payment_razorpay/tests/common.py
new file mode 100644
index 00000000000..fb0a6d9c323
--- /dev/null
+++ b/addons/payment_razorpay/tests/common.py
@@ -0,0 +1,43 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.payment.tests.common import PaymentCommon
+
+
+class RazorpayCommon(PaymentCommon):
+
+ @classmethod
+ def setUpClass(cls, chart_template_ref=None):
+ super().setUpClass(chart_template_ref=chart_template_ref)
+
+ cls.acquirer = cls._prepare_acquirer('razorpay', update_values={
+ 'razorpay_key_id': 'rzp_123',
+ 'razorpay_key_secret': 'Y63AyP9eL91',
+ 'razorpay_webhook_secret': 'coincoin_motherducker',
+ })
+
+ cls.payment_id = 'pay_123'
+ cls.refund_id = 'rfd_456'
+ cls.order_id = 'order_789'
+ cls.redirect_notification_data = {
+ 'razorpay_payment_id': cls.payment_id,
+ 'razorpay_order_id': cls.order_id,
+ 'razorpay_signature': 'dummy',
+ }
+ cls.payment_data = {
+ 'id': cls.payment_id,
+ 'description': cls.reference,
+ 'status': 'captured',
+ }
+ cls.refund_data = {
+ 'id': cls.refund_id,
+ 'payment_id': cls.payment_id,
+ 'amount': cls.amount,
+ }
+ cls.webhook_notification_data = {
+ 'event': 'payment.captured',
+ 'payload': {
+ 'payment': {
+ 'entity': cls.payment_data,
+ },
+ },
+ }
diff --git a/addons/payment_razorpay/tests/test_payment_acquirer.py b/addons/payment_razorpay/tests/test_payment_acquirer.py
new file mode 100644
index 00000000000..404c1ed92ae
--- /dev/null
+++ b/addons/payment_razorpay/tests/test_payment_acquirer.py
@@ -0,0 +1,33 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.tests import tagged
+
+from odoo.addons.payment_razorpay.tests.common import RazorpayCommon
+
+
+@tagged('post_install', '-at_install')
+class TestPaymentAcquirer(RazorpayCommon):
+
+ def test_incompatible_with_unsupported_currencies(self):
+ """ Test that Razorpay 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.acquirer, compatible_acquirers)
+
+ def test_signature_calculation_for_redirect_data(self):
+ """ Test that the calculated signature matches the expected signature for redirect data. """
+ calculated_signature = self.acquirer._razorpay_calculate_signature(
+ self.redirect_notification_data, is_redirect=True
+ )
+ self.assertEqual(
+ calculated_signature, '437b72e4e87362a39951b44487cf698410b074afdbed19ec44fffd32d2f863f3'
+ )
+
+ def test_neutralize(self):
+ """ Test that the sensitive fields of the acquirer are correctly neutralized. """
+ self.env['payment.acquirer']._neutralize()
+ self.assertFalse(self.acquirer.razorpay_key_id)
+ self.assertFalse(self.acquirer.razorpay_key_secret)
+ self.assertFalse(self.acquirer.razorpay_webhook_secret)
diff --git a/addons/payment_razorpay/tests/test_payment_transaction.py b/addons/payment_razorpay/tests/test_payment_transaction.py
new file mode 100644
index 00000000000..4162eeb1b27
--- /dev/null
+++ b/addons/payment_razorpay/tests/test_payment_transaction.py
@@ -0,0 +1,70 @@
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from unittest.mock import patch
+
+from odoo.exceptions import UserError
+from odoo.tests import tagged
+
+from odoo.addons.payment import utils as payment_utils
+from odoo.addons.payment_razorpay.tests.common import RazorpayCommon
+
+
+@tagged('post_install', '-at_install')
+class TestPaymentTransaction(RazorpayCommon):
+
+ def test_no_item_missing_from_order_request_payload(self):
+ """ Test that the request values are conform to the transaction fields. """
+ tx = self._create_transaction('redirect')
+ request_payload = tx._razorpay_prepare_order_request_payload()
+ self.maxDiff = 10000 # Allow comparing large dicts.
+ converted_amount = payment_utils.to_minor_currency_units(tx.amount, tx.currency_id)
+ self.assertDictEqual(request_payload, {
+ 'amount': converted_amount,
+ 'currency': tx.currency_id.name,
+ })
+
+ def test_void_is_not_supported(self):
+ """ Test that trying to void an authorized transaction raises an error. """
+ tx = self._create_transaction('redirect', state='authorized')
+ self.assertRaises(UserError, func=tx._send_void_request)
+
+ def test_get_tx_from_notification_data_returns_refund_tx(self):
+ """ Test that the refund transaction is returned if it exists when processing refund
+ notification data. """
+ refund_tx = self._create_transaction('redirect')
+ returned_tx = self.env['payment.transaction']._get_tx_from_notification_data(
+ 'razorpay', dict(self.refund_data, **{
+ 'entity_type': 'refund',
+ 'notes': {
+ 'reference': refund_tx.reference,
+ },
+ })
+ )
+ self.assertEqual(returned_tx, refund_tx)
+
+ def test_get_tx_from_notification_data_creates_refund_tx_when_missing(self):
+ """ Test that a refund transaction is created when processing refund notification data
+ without reference. """
+ source_tx = self._create_transaction(
+ 'redirect', state='done', acquirer_reference=self.payment_id
+ )
+ refund_tx = self.env['payment.transaction']._get_tx_from_notification_data(
+ 'razorpay', dict(self.refund_data, entity_type='refund')
+ )
+ self.assertTrue(
+ refund_tx,
+ msg="If no refund tx is found with the refund data, a refund tx should be created.",
+ )
+ self.assertNotEqual(refund_tx, source_tx)
+ self.assertEqual(refund_tx.source_transaction_id, source_tx)
+
+ 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('redirect')
+ with patch(
+ 'odoo.addons.payment_razorpay.models.payment_acquirer.PaymentAcquirer'
+ '._razorpay_make_request', return_value=self.payment_data
+ ):
+ tx._process_notification_data(self.payment_data)
+ self.assertEqual(tx.state, 'done')
diff --git a/addons/payment_razorpay/tests/test_processing_flows.py b/addons/payment_razorpay/tests/test_processing_flows.py
new file mode 100644
index 00000000000..b01ca01facd
--- /dev/null
+++ b/addons/payment_razorpay/tests/test_processing_flows.py
@@ -0,0 +1,122 @@
+# 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_razorpay.controllers.main import RazorpayController
+from odoo.addons.payment_razorpay.tests.common import RazorpayCommon
+
+
+@tagged('post_install', '-at_install')
+class TestProcessingFlows(RazorpayCommon, PaymentHttpCommon):
+
+ @mute_logger('odoo.addons.payment_razorpay.controllers.main')
+ def test_redirect_notification_triggers_processing(self):
+ """ Test that receiving a redirect notification triggers the processing of the notification
+ data. """
+ self._create_transaction('redirect')
+ url = self._build_url(f'{RazorpayController._return_url}?reference={self.reference}')
+ with patch(
+ 'odoo.addons.payment_razorpay.controllers.main.RazorpayController'
+ '._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.redirect_notification_data)
+ self.assertEqual(handle_notification_data_mock.call_count, 1)
+
+ @mute_logger('odoo.addons.payment_razorpay.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('direct')
+ url = self._build_url(RazorpayController._webhook_url)
+ with patch(
+ 'odoo.addons.payment_razorpay.controllers.main.RazorpayController.'
+ '_verify_notification_signature'
+ ), patch(
+ 'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
+ '._handle_notification_data'
+ ) as handle_notification_data_mock:
+ self._make_json_request(url, data=self.webhook_notification_data)
+ self.assertEqual(handle_notification_data_mock.call_count, 1)
+
+ @mute_logger('odoo.addons.payment_razorpay.controllers.main')
+ def test_redirect_notification_triggers_signature_check(self):
+ """ Test that receiving a redirect notification triggers a signature check. """
+ self._create_transaction('redirect')
+ url = self._build_url(f'{RazorpayController._return_url}?reference={self.reference}')
+ with patch(
+ 'odoo.addons.payment_razorpay.controllers.main.RazorpayController'
+ '._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.redirect_notification_data)
+ self.assertEqual(signature_check_mock.call_count, 1)
+
+ @mute_logger('odoo.addons.payment_razorpay.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(RazorpayController._webhook_url)
+ with patch(
+ 'odoo.addons.payment_razorpay.controllers.main.RazorpayController'
+ '._verify_notification_signature'
+ ) as signature_check_mock, patch(
+ 'odoo.addons.payment.models.payment_transaction.PaymentTransaction'
+ '._handle_notification_data'
+ ):
+ self._make_json_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')
+ with patch(
+ 'odoo.addons.payment_razorpay.models.payment_acquirer.PaymentAcquirer'
+ '._razorpay_calculate_signature', return_value='valid_signature'
+ ):
+ self._assert_does_not_raise(
+ Forbidden,
+ RazorpayController._verify_notification_signature,
+ self.webhook_notification_data,
+ 'valid_signature',
+ tx,
+ is_redirect=False,
+ )
+
+ @mute_logger('odoo.addons.payment_razorpay.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')
+ self.assertRaises(
+ Forbidden,
+ RazorpayController._verify_notification_signature,
+ self.webhook_notification_data,
+ None,
+ tx,
+ )
+
+ @mute_logger('odoo.addons.payment_razorpay.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')
+ with patch(
+ 'odoo.addons.payment_razorpay.models.payment_acquirer.PaymentAcquirer'
+ '._razorpay_calculate_signature', return_value='valid_signature'
+ ):
+ self.assertRaises(
+ Forbidden,
+ RazorpayController._verify_notification_signature,
+ self.webhook_notification_data,
+ 'bad_signature',
+ tx,
+ )
diff --git a/addons/payment_razorpay/views/payment_razorpay_templates.xml b/addons/payment_razorpay/views/payment_razorpay_templates.xml
new file mode 100644
index 00000000000..10b868401ed
--- /dev/null
+++ b/addons/payment_razorpay/views/payment_razorpay_templates.xml
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
diff --git a/addons/payment_razorpay/views/payment_views.xml b/addons/payment_razorpay/views/payment_views.xml
new file mode 100644
index 00000000000..dab885ee257
--- /dev/null
+++ b/addons/payment_razorpay/views/payment_views.xml
@@ -0,0 +1,28 @@
+
+
+
+
+ Razorpay Acquirer Form
+ payment.acquirer
+
+
+
+
+
+
+
+
+
+
+
+
+