# 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 from odoo.addons.phone_validation.tools.phone_validation import phone_sanitize_numbers _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 provider-specific rendering values. :rtype: dict """ res = super()._get_specific_rendering_values(processing_values) if self.provider_code != '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.provider_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.provider_id.get_base_url() return_url_params = {'reference': self.reference} phone = self.partner_phone error_message = _("The phone number is missing.") if phone: # sanitize partner phone country_code = self.partner_country_id.code country_phone_code = self.partner_country_id.phone_code phone_info = phone_sanitize_numbers([phone], country_code, country_phone_code) phone = phone_info[self.partner_phone]['sanitized'] error_message = phone_info[self.partner_phone]['msg'] if not phone: raise ValidationError("Razorpay: " + error_message) rendering_values = { 'key_id': self.provider_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': 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.provider_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): """ Override of `payment` to send a refund request to Razorpay. Note: self.ensure_one() :param float amount_to_refund: The amount to refund. :return: The refund transaction created to process the refund request. :rtype: recordset of `payment.transaction` """ refund_tx = super()._send_refund_request(amount_to_refund=amount_to_refund) if self.provider_code != 'razorpay': return refund_tx # 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.provider_id._razorpay_make_request( f'payments/{self.provider_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_code != '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.provider_id._razorpay_make_request( f'payments/{self.provider_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_code != 'razorpay': return raise UserError(_("Transactions processed by Razorpay can't be manually voided from Odoo.")) def _get_tx_from_notification_data(self, provider_code, notification_data): """ Override of `payment` to find the transaction based on razorpay data. :param str provider_code: The code of the provider 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_code, notification_data) if provider_code != '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_code', '=', 'razorpay')]) else: # 'refund' notes = notification_data.get('notes') reference = isinstance(notes, dict) and notes.get('reference') if reference: # The refund was initiated from Odoo. tx = self.search([('reference', '=', reference), ('provider_code', '=', 'razorpay')]) else: # The refund was initiated from Razorpay. # Find the source transaction based on its provider reference. source_tx = self.search([ ('provider_reference', '=', notification_data['payment_id']), ('provider_code', '=', '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_provider_reference = notification_data.get('id') amount_to_refund = notification_data.get('amount') if not refund_provider_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, provider_reference=refund_provider_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_code != '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.provider_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.provider_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) )