Odoo18-Base/addons/payment_razorpay/models/payment_transaction.py
2025-03-10 11:12:23 +07:00

311 lines
13 KiB
Python

# 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/<id>/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/<id>/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/<id>/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/<id>/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)
)