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 + + + + + + + + + + + + +