[IMP] payment_razorpay: support tokenization in Razorpay's "Payment" gateway
See README for technical details. task-3495914 closes odoo/odoo#143525 Related: odoo/documentation#6732 Signed-off-by: Kartik Chavda (kcv) <kcv@odoo.com>
This commit is contained in:
@@ -3150,7 +3150,7 @@
|
||||
<field name="sequence">1000</field>
|
||||
<field name="active">False</field>
|
||||
<field name="image" type="base64" file="payment/static/img/upi.png"/>
|
||||
<field name="support_tokenization">False</field>
|
||||
<field name="support_tokenization">True</field>
|
||||
<field name="support_express_checkout">False</field>
|
||||
<field name="support_refund">partial</field>
|
||||
<field name="supported_country_ids"
|
||||
|
||||
@@ -4,33 +4,38 @@
|
||||
|
||||
### Supported features
|
||||
|
||||
- Payment with redirection flow
|
||||
- Direct payment flow
|
||||
- Tokenization
|
||||
- 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.
|
||||
[Razorpay Recurring Payments](https://razorpay.com/docs/api/payments/recurring-payments/), which is
|
||||
more complex to handle than
|
||||
[Razorpay Hosted Checkout](https://razorpay.com/docs/payments/payment-gateway/web-integration/hosted)
|
||||
because it works as a direct payment flow, because it allows for tokenization. The other gateways
|
||||
were ruled out; see the original task's dev notes for the details on the other gateways.
|
||||
|
||||
The version of the API implemented by this module is v1.
|
||||
|
||||
## Merge details
|
||||
## Module history
|
||||
|
||||
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`.
|
||||
- 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`.
|
||||
- The API was changed to the Recurring Payments API to support tokenization with PR odoo/odoo#143525
|
||||
in `17.0`.
|
||||
|
||||
## Testing instructions
|
||||
|
||||
The partner's phone number must be a valid Indian phone number. Example: +91123456789
|
||||
- The partner's phone number must be a valid Indian phone number. Example: +911234567890
|
||||
- The partner's country must be India and the payment currency INR to enable India-based payment
|
||||
methods.
|
||||
|
||||
See https://razorpay.com/docs/payments/payments/test-card-upi-details/ for the list of test
|
||||
payment details.
|
||||
See https://razorpay.com/docs/payments/payments/test-card-upi-details/ and
|
||||
https://razorpay.com/docs/payments/payments/test-upi-details/ for the list of test payment details.
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
|
||||
'data/payment_provider_data.xml', # Depends on views/payment_razorpay_templates.xml
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_frontend': [
|
||||
'payment_razorpay/static/src/js/payment_form.js',
|
||||
],
|
||||
},
|
||||
'post_init_hook': 'post_init_hook',
|
||||
'uninstall_hook': 'uninstall_hook',
|
||||
'license': 'LGPL-3',
|
||||
|
||||
@@ -111,6 +111,12 @@ DEFAULT_PAYMENT_METHODS_CODES = [
|
||||
'discover',
|
||||
]
|
||||
|
||||
# The maximum amount in INR that can be paid through an eMandate.
|
||||
MANDATE_MAX_AMOUNT = {
|
||||
'card': 500000,
|
||||
'upi': 100000,
|
||||
}
|
||||
|
||||
# Mapping of transaction states to Razorpay's payment statuses.
|
||||
# See https://razorpay.com/docs/payments/payments#payment-life-cycle.
|
||||
PAYMENT_STATUS_MAPPING = {
|
||||
|
||||
@@ -25,25 +25,7 @@ class RazorpayController(http.Controller):
|
||||
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.
|
||||
# TODO: Remove me in master
|
||||
return request.redirect('/payment/status')
|
||||
|
||||
@http.route(_webhook_url, type='http', methods=['POST'], auth='public', csrf=False)
|
||||
@@ -81,7 +63,7 @@ class RazorpayController(http.Controller):
|
||||
@staticmethod
|
||||
def _verify_notification_signature(
|
||||
notification_data, received_signature, tx_sudo, is_redirect=True
|
||||
):
|
||||
): # TODO in master: remove the `is_redirect` parameter.
|
||||
""" Check that the received signature matches the expected one.
|
||||
|
||||
:param dict|bytes notification_data: The notification data.
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
#
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Odoo Server 17.0\n"
|
||||
"Project-Id-Version: Odoo Server 17.0+e\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-10-26 21:56+0000\n"
|
||||
"PO-Revision-Date: 2023-10-26 21:56+0000\n"
|
||||
"POT-Creation-Date: 2023-12-22 15:54+0000\n"
|
||||
"PO-Revision-Date: 2023-12-22 15:54+0000\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: \n"
|
||||
"MIME-Version: 1.0\n"
|
||||
@@ -15,6 +15,13 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: \n"
|
||||
"Plural-Forms: \n"
|
||||
|
||||
#. module: payment_razorpay
|
||||
#: model_terms:ir.ui.view,arch_db:payment_razorpay.payment_provider_form_razorpay
|
||||
msgid ""
|
||||
"<i class=\"oi oi-fw o_button_icon oi-arrow-right\"/>\n"
|
||||
" Enable recurring payments on Razorpay"
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-python
|
||||
#: code:addons/payment_razorpay/models/payment_transaction.py:0
|
||||
@@ -62,6 +69,13 @@ msgstr ""
|
||||
msgid "Payment Transaction"
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-javascript
|
||||
#: code:addons/payment_razorpay/static/src/js/payment_form.js:0
|
||||
#, python-format
|
||||
msgid "Payment processing failed"
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#: model:ir.model.fields.selection,name:payment_razorpay.selection__payment_provider__code__razorpay
|
||||
msgid "Razorpay"
|
||||
@@ -82,6 +96,13 @@ msgstr ""
|
||||
msgid "Razorpay Webhook Secret"
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-python
|
||||
#: code:addons/payment_razorpay/models/payment_provider.py:0
|
||||
#, python-format
|
||||
msgid "Razorpay gave us the following information: '%s'"
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-python
|
||||
#: code:addons/payment_razorpay/models/payment_transaction.py:0
|
||||
@@ -118,12 +139,8 @@ msgid "Received incomplete refund data."
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-python
|
||||
#: code:addons/payment_razorpay/models/payment_provider.py:0
|
||||
#, python-format
|
||||
msgid ""
|
||||
"The communication with the API failed. Razorpay gave us the following "
|
||||
"information: '%s'"
|
||||
#: model_terms:ir.ui.view,arch_db:payment_razorpay.redirect_form
|
||||
msgid "TODO: remove in master"
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
@@ -131,6 +148,13 @@ msgstr ""
|
||||
msgid "The key solely used to identify the account with Razorpay."
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-python
|
||||
#: code:addons/payment_razorpay/models/payment_transaction.py:0
|
||||
#, python-format
|
||||
msgid "The phone number is invalid."
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-python
|
||||
#: code:addons/payment_razorpay/models/payment_transaction.py:0
|
||||
@@ -143,6 +167,13 @@ msgstr ""
|
||||
msgid "The technical code of this payment provider."
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-python
|
||||
#: code:addons/payment_razorpay/models/payment_transaction.py:0
|
||||
#, python-format
|
||||
msgid "The transaction is not linked to a token."
|
||||
msgstr ""
|
||||
|
||||
#. module: payment_razorpay
|
||||
#. odoo-python
|
||||
#: code:addons/payment_razorpay/models/payment_transaction.py:0
|
||||
|
||||
@@ -47,6 +47,7 @@ class PaymentProvider(models.Model):
|
||||
self.filtered(lambda p: p.code == 'razorpay').update({
|
||||
'support_manual_capture': 'full_only',
|
||||
'support_refund': 'partial',
|
||||
'support_tokenization': True,
|
||||
})
|
||||
|
||||
# === BUSINESS METHODS ===#
|
||||
@@ -88,8 +89,8 @@ class PaymentProvider(models.Model):
|
||||
"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')
|
||||
"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)
|
||||
@@ -126,3 +127,15 @@ class PaymentProvider(models.Model):
|
||||
if self.code != 'razorpay':
|
||||
return default_codes
|
||||
return const.DEFAULT_PAYMENT_METHODS_CODES
|
||||
|
||||
def _get_validation_amount(self):
|
||||
""" Override of `payment` to return the amount for Razorpay validation operations.
|
||||
|
||||
:return: The validation amount.
|
||||
:rtype: float
|
||||
"""
|
||||
res = super()._get_validation_amount()
|
||||
if self.code != 'razorpay':
|
||||
return res
|
||||
|
||||
return 1.0
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import logging
|
||||
import pprint
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from werkzeug.urls import url_encode, url_join
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from odoo import _, models
|
||||
from odoo import _, api, models
|
||||
from odoo.exceptions import UserError, ValidationError
|
||||
|
||||
from odoo.addons.payment import utils as payment_utils
|
||||
from odoo.addons.payment_razorpay import const
|
||||
from odoo.addons.payment_razorpay.controllers.main import RazorpayController
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -19,76 +20,121 @@ _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.
|
||||
def _get_specific_processing_values(self, processing_values):
|
||||
""" Override of `payment` to return razorpay-specific processing 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.
|
||||
:return: The provider-specific processing values.
|
||||
:rtype: dict
|
||||
"""
|
||||
res = super()._get_specific_rendering_values(processing_values)
|
||||
res = super()._get_specific_processing_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()
|
||||
customer_id = self._razorpay_create_customer()['id']
|
||||
order_id = self._razorpay_create_order(customer_id)['id']
|
||||
return {
|
||||
'razorpay_key_id': self.provider_id.razorpay_key_id,
|
||||
'razorpay_customer_id': customer_id,
|
||||
'is_tokenize_request': self.tokenize,
|
||||
'razorpay_order_id': order_id,
|
||||
}
|
||||
|
||||
def _razorpay_create_customer(self):
|
||||
""" Create and return a Customer object.
|
||||
|
||||
:return: The created Customer.
|
||||
:rtype: dict
|
||||
"""
|
||||
payload = {
|
||||
'name': self.partner_name,
|
||||
'email': self.partner_email,
|
||||
'contact': self._validate_phone_number(self.partner_phone),
|
||||
'fail_existing': '0', # Don't throw an error if the customer already exists.
|
||||
}
|
||||
_logger.info(
|
||||
"Payload of '/orders' request for transaction with reference %s:\n%s",
|
||||
"Sending '/customers' 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)
|
||||
customer_data = self.provider_id._razorpay_make_request('customers', payload=payload)
|
||||
_logger.info(
|
||||
"Response of '/customers' request for transaction with reference %s:\n%s",
|
||||
self.reference, pprint.pformat(customer_data)
|
||||
)
|
||||
return customer_data
|
||||
|
||||
@api.model
|
||||
def _validate_phone_number(self, phone):
|
||||
""" Validate and format the phone number.
|
||||
|
||||
:param str phone: The phone number to validate.
|
||||
:return str: The formatted phone number.
|
||||
:raise ValidationError: If the phone number is missing or incorrect.
|
||||
"""
|
||||
if not phone:
|
||||
raise ValidationError("Razorpay: " + _("The phone number is missing."))
|
||||
|
||||
try:
|
||||
phone = self._phone_format(
|
||||
number=phone, country=self.partner_country_id, raise_exception=True
|
||||
)
|
||||
except Exception:
|
||||
raise ValidationError("Razorpay: " + _("The phone number is invalid."))
|
||||
return phone
|
||||
|
||||
def _razorpay_create_order(self, customer_id=None):
|
||||
""" Create and return an Order object to initiate the payment.
|
||||
|
||||
:param str customer_id: The ID of the Customer object to assign to the Order for
|
||||
non-subsequent payments.
|
||||
:return: The created Order.
|
||||
:rtype: dict
|
||||
"""
|
||||
payload = self._razorpay_prepare_order_payload(customer_id=customer_id)
|
||||
_logger.info(
|
||||
"Sending '/orders' request for transaction with reference %s:\n%s",
|
||||
self.reference, pprint.pformat(payload)
|
||||
)
|
||||
order_data = self.provider_id._razorpay_make_request('orders', payload=payload)
|
||||
_logger.info(
|
||||
"Response of '/orders' request for transaction with reference %s:\n%s",
|
||||
self.reference, pprint.pformat(order_data)
|
||||
)
|
||||
return 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
|
||||
if phone:
|
||||
# sanitize partner phone
|
||||
try:
|
||||
phone = self._phone_format(number=phone, country=self.partner_country_id, raise_exception=True)
|
||||
except Exception as err:
|
||||
raise ValidationError("Razorpay: " + str(err)) from err
|
||||
else:
|
||||
raise ValidationError("Razorpay: " + _("The phone number is missing."))
|
||||
|
||||
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,
|
||||
'method': self.payment_method_code,
|
||||
'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.
|
||||
def _razorpay_prepare_order_payload(self, customer_id=None):
|
||||
""" Prepare the payload for the order request based on the transaction values.
|
||||
|
||||
:param str customer_id: The ID of the Customer object to assign to the Order for
|
||||
non-subsequent payments.
|
||||
:return: The request payload.
|
||||
:rtype: dict
|
||||
"""
|
||||
converted_amount = payment_utils.to_minor_currency_units(self.amount, self.currency_id)
|
||||
pm_code = (self.payment_method_id.primary_payment_method_id or self.payment_method_id).code
|
||||
payload = {
|
||||
'amount': converted_amount,
|
||||
'currency': self.currency_id.name,
|
||||
'method': pm_code,
|
||||
}
|
||||
if self.operation in ['online_direct', 'validation']:
|
||||
payload['customer_id'] = customer_id # Required for only non-subsequent payments.
|
||||
if self.tokenize:
|
||||
payload['token'] = {
|
||||
'max_amount': payment_utils.to_minor_currency_units(
|
||||
self._get_mandate_max_amount(), self.currency_id
|
||||
),
|
||||
'expire_at': time.mktime(
|
||||
(datetime.today() + relativedelta(years=10)).timetuple()
|
||||
), # Don't expire the token before at least 10 years.
|
||||
'frequency': 'as_presented',
|
||||
}
|
||||
else: # 'online_token', 'offline'
|
||||
# Required for only subsequent payments.
|
||||
payload['payment_capture'] = not self.provider_id.capture_manually
|
||||
if self.provider_id.capture_manually: # The related payment must be only authorized.
|
||||
payload.update({
|
||||
'payment': {
|
||||
@@ -101,6 +147,70 @@ class PaymentTransaction(models.Model):
|
||||
})
|
||||
return payload
|
||||
|
||||
def _get_mandate_max_amount(self):
|
||||
""" Return the eMandate's maximum amount to define.
|
||||
|
||||
:return: The eMandate's maximum amount.
|
||||
:rtype: int
|
||||
"""
|
||||
mandate_values = self._get_mandate_values()
|
||||
if 'amount' in mandate_values:
|
||||
max_amount = mandate_values['amount'] * 5 # FP's rule of thumb for a good max amount.
|
||||
else:
|
||||
pm_code = (
|
||||
self.payment_method_id.primary_payment_method_id or self.payment_method_id
|
||||
).code
|
||||
max_amount = const.MANDATE_MAX_AMOUNT.get(pm_code, 100000)
|
||||
return max_amount
|
||||
|
||||
def _send_payment_request(self):
|
||||
""" Override of `payment` to send a payment request to Razorpay.
|
||||
|
||||
Note: self.ensure_one()
|
||||
|
||||
:return: None
|
||||
:raise UserError: If the transaction is not linked to a token.
|
||||
"""
|
||||
super()._send_payment_request()
|
||||
if self.provider_code != 'razorpay':
|
||||
return
|
||||
|
||||
if not self.token_id:
|
||||
raise UserError("Razorpay: " + _("The transaction is not linked to a token."))
|
||||
|
||||
try:
|
||||
order_data = self._razorpay_create_order()
|
||||
phone = self._validate_phone_number(self.partner_phone)
|
||||
customer_id, token_id = self.token_id.provider_ref.split(',')
|
||||
payload = {
|
||||
'email': self.partner_email,
|
||||
'contact': phone,
|
||||
'amount': order_data['amount'],
|
||||
'currency': self.currency_id.name,
|
||||
'order_id': order_data['id'],
|
||||
'customer_id': customer_id,
|
||||
'token': token_id,
|
||||
'description': self.reference,
|
||||
'recurring': '1',
|
||||
}
|
||||
_logger.info(
|
||||
"Sending '/payments/create/recurring' request for transaction with reference %s:\n%s",
|
||||
self.reference, pprint.pformat(payload)
|
||||
)
|
||||
recurring_payment_data = self.provider_id._razorpay_make_request(
|
||||
'payments/create/recurring', payload=payload
|
||||
)
|
||||
_logger.info(
|
||||
"Response of '/payments/create/recurring' request for transaction with reference "
|
||||
"%s:\n%s", self.reference, pprint.pformat(recurring_payment_data)
|
||||
)
|
||||
self._handle_notification_data('razorpay', recurring_payment_data)
|
||||
except ValidationError as e:
|
||||
if self.operation == 'offline':
|
||||
self._set_error(str(e))
|
||||
else:
|
||||
raise
|
||||
|
||||
def _send_refund_request(self, amount_to_refund=None):
|
||||
""" Override of `payment` to send a refund request to Razorpay.
|
||||
|
||||
@@ -286,6 +396,8 @@ class PaymentTransaction(models.Model):
|
||||
elif entity_status in const.PAYMENT_STATUS_MAPPING['authorized']:
|
||||
self._set_authorized()
|
||||
elif entity_status in const.PAYMENT_STATUS_MAPPING['done']:
|
||||
if self.tokenize:
|
||||
self._razorpay_tokenize_from_notification_data(notification_data)
|
||||
self._set_done()
|
||||
|
||||
# Immediately post-process the transaction if it is a refund, as the post-processing
|
||||
@@ -308,3 +420,41 @@ class PaymentTransaction(models.Model):
|
||||
self._set_error(
|
||||
"Razorpay: " + _("Received data with invalid status: %s", entity_status)
|
||||
)
|
||||
|
||||
def _razorpay_tokenize_from_notification_data(self, notification_data):
|
||||
""" Create a new token based on the notification data.
|
||||
|
||||
:param dict notification_data: The notification data built with Razorpay objects.
|
||||
See `_process_notification_data`.
|
||||
:return: None
|
||||
"""
|
||||
pm_code = (self.payment_method_id.primary_payment_method_id or self.payment_method_id).code
|
||||
if pm_code == 'card':
|
||||
details = notification_data.get('card', {}).get('last4')
|
||||
elif pm_code == 'upi':
|
||||
temp_vpa = notification_data.get('vpa')
|
||||
details = temp_vpa[temp_vpa.find('@') - 1:]
|
||||
else:
|
||||
details = pm_code
|
||||
|
||||
token = self.env['payment.token'].create({
|
||||
'provider_id': self.provider_id.id,
|
||||
'payment_method_id': self.payment_method_id.id,
|
||||
'payment_details': details,
|
||||
'partner_id': self.partner_id.id,
|
||||
# Razorpay requires both the customer ID and the token ID which are extracted from here.
|
||||
'provider_ref': f'{notification_data["customer_id"]},{notification_data["token_id"]}',
|
||||
})
|
||||
self.write({
|
||||
'token_id': token,
|
||||
'tokenize': False,
|
||||
})
|
||||
_logger.info(
|
||||
"Created token with id %(token_id)s for partner with id %(partner_id)s from "
|
||||
"transaction with reference %(ref)s",
|
||||
{
|
||||
'token_id': token.id,
|
||||
'partner_id': self.partner_id.id,
|
||||
'ref': self.reference,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** @odoo-module **/
|
||||
/* global Razorpay */
|
||||
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { loadJS } from "@web/core/assets";
|
||||
import paymentForm from '@payment/js/payment_form';
|
||||
|
||||
paymentForm.include({
|
||||
|
||||
// #=== DOM MANIPULATION ===#
|
||||
|
||||
/**
|
||||
* Update the payment context to set the flow to 'direct'.
|
||||
*
|
||||
* @override method from @payment/js/payment_form
|
||||
* @private
|
||||
* @param {number} providerId - The id of the selected payment option's provider.
|
||||
* @param {string} providerCode - The code of the selected payment option's provider.
|
||||
* @param {number} paymentOptionId - The id of the selected payment option
|
||||
* @param {string} paymentMethodCode - The code of the selected payment method, if any.
|
||||
* @param {string} flow - The online payment flow of the selected payment option.
|
||||
* @return {void}
|
||||
*/
|
||||
async _prepareInlineForm(providerId, providerCode, paymentOptionId, paymentMethodCode, flow) {
|
||||
if (providerCode !== 'razorpay') {
|
||||
this._super(...arguments);
|
||||
return;
|
||||
}
|
||||
|
||||
if (flow === 'token') {
|
||||
return; // No need to update the flow for tokens.
|
||||
}
|
||||
|
||||
// Overwrite the flow of the select payment method.
|
||||
this._setPaymentFlow('direct');
|
||||
},
|
||||
|
||||
// #=== PAYMENT FLOW ===#
|
||||
|
||||
async _processDirectFlow(providerCode, paymentOptionId, paymentMethodCode, processingValues) {
|
||||
if (providerCode !== 'razorpay') {
|
||||
this._super(...arguments);
|
||||
return;
|
||||
}
|
||||
const razorpayOptions = this._prepareRazorpayOptions(processingValues);
|
||||
await loadJS('https://checkout.razorpay.com/v1/checkout.js');
|
||||
const RazorpayJS = Razorpay(razorpayOptions);
|
||||
RazorpayJS.open();
|
||||
RazorpayJS.on('payment.failed', response => {
|
||||
this._displayErrorDialog(_t("Payment processing failed"), response.error.description);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Prepare the options to init the RazorPay SDK Object.
|
||||
*
|
||||
* @param {object} processingValues - The processing values.
|
||||
* @return {object}
|
||||
*/
|
||||
_prepareRazorpayOptions(processingValues) {
|
||||
return Object.assign({}, processingValues, {
|
||||
'key': processingValues['razorpay_key_id'],
|
||||
'order_id': processingValues['razorpay_order_id'],
|
||||
'customer_id': processingValues['razorpay_customer_id'],
|
||||
'description': processingValues['reference'],
|
||||
'recurring': processingValues['is_tokenize_request'] ? '1': '0',
|
||||
'handler': response => {
|
||||
if (
|
||||
response['razorpay_payment_id']
|
||||
&& response['razorpay_order_id']
|
||||
&& response['razorpay_signature']
|
||||
) { // The payment reached a final state; redirect to the status page.
|
||||
window.location = '/payment/status';
|
||||
}
|
||||
},
|
||||
'modal': {
|
||||
'ondismiss': () => {
|
||||
window.location.reload();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo.addons.payment.tests.common import PaymentCommon
|
||||
from odoo.fields import Command
|
||||
|
||||
|
||||
class RazorpayCommon(PaymentCommon):
|
||||
@@ -13,8 +14,12 @@ class RazorpayCommon(PaymentCommon):
|
||||
'razorpay_key_id': 'rzp_123',
|
||||
'razorpay_key_secret': 'Y63AyP9eL91',
|
||||
'razorpay_webhook_secret': 'coincoin_motherducker',
|
||||
'payment_method_ids': [Command.set([cls.env.ref('payment.payment_method_card').id])],
|
||||
})
|
||||
|
||||
cls.razorpay_customer_id = 'cust_123'
|
||||
cls.razorpay_token_id = 'token_404'
|
||||
|
||||
cls.payment_id = 'pay_123'
|
||||
cls.refund_id = 'rfd_456'
|
||||
cls.order_id = 'order_789'
|
||||
@@ -23,11 +28,17 @@ class RazorpayCommon(PaymentCommon):
|
||||
'razorpay_order_id': cls.order_id,
|
||||
'razorpay_signature': 'dummy',
|
||||
}
|
||||
cls.payment_method_id = cls.provider.payment_method_ids[:1].id
|
||||
cls.payment_data = {
|
||||
'id': cls.payment_id,
|
||||
'description': cls.reference,
|
||||
'status': 'captured',
|
||||
}
|
||||
cls.tokenize_payment_data = {
|
||||
**cls.payment_data,
|
||||
'customer_id': cls.razorpay_customer_id,
|
||||
'token_id': cls.razorpay_token_id,
|
||||
}
|
||||
cls.refund_data = {
|
||||
'id': cls.refund_id,
|
||||
'payment_id': cls.payment_id,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import time
|
||||
|
||||
from datetime import datetime
|
||||
from dateutil.relativedelta import relativedelta
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
@@ -14,13 +19,15 @@ 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()
|
||||
tx = self._create_transaction('redirect', operation='online_direct', payment_method_id=self.payment_method_id)
|
||||
request_payload = tx._razorpay_prepare_order_payload(customer_id=self.razorpay_customer_id)
|
||||
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,
|
||||
'customer_id': self.razorpay_customer_id,
|
||||
'method': 'card',
|
||||
})
|
||||
|
||||
def test_void_is_not_supported(self):
|
||||
@@ -68,3 +75,44 @@ class TestPaymentTransaction(RazorpayCommon):
|
||||
):
|
||||
tx._process_notification_data(self.payment_data)
|
||||
self.assertEqual(tx.state, 'done')
|
||||
|
||||
def test_order_request_payload_for_tokenize_tx(self):
|
||||
""" Test that order payload for tokenize tx is proper. """
|
||||
tx = self._create_transaction('redirect', operation='online_direct', tokenize=True, payment_method_id=self.payment_method_id)
|
||||
self.assertDictEqual(tx._get_specific_rendering_values(None), {}, "Should return empty dict of rendering values for tokenize transaction")
|
||||
|
||||
request_payload = tx._razorpay_prepare_order_payload(customer_id=self.razorpay_customer_id)
|
||||
converted_amount = payment_utils.to_minor_currency_units(tx.amount, tx.currency_id)
|
||||
token_expiry_date = datetime.today() + relativedelta(years=10)
|
||||
token_expiry_timeslamp = time.mktime(token_expiry_date.timetuple())
|
||||
self.assertDictEqual(request_payload, {
|
||||
'token': {
|
||||
"expire_at": token_expiry_timeslamp,
|
||||
"frequency": "as_presented",
|
||||
'max_amount': 50000000,
|
||||
},
|
||||
'amount': converted_amount,
|
||||
'currency': tx.currency_id.name,
|
||||
'customer_id': self.razorpay_customer_id,
|
||||
'method': 'card',
|
||||
|
||||
})
|
||||
|
||||
def test_processing_notification_data_confirms_tokenize_transaction(self):
|
||||
""" Test that the transaction state is set to 'done' when the notification data indicate a
|
||||
successful payment. """
|
||||
tx = self._create_transaction('redirect', tokenize=True, payment_method_id=self.payment_method_id)
|
||||
tx._process_notification_data(self.tokenize_payment_data)
|
||||
self.assertEqual(tx.state, 'done')
|
||||
|
||||
def test_token_creation_for_tokenize_transaction(self):
|
||||
""" Test that the token is create on confirmation of tokenize transaction """
|
||||
tx = self._create_transaction('redirect', tokenize=True, payment_method_id=self.payment_method_id)
|
||||
tx._process_notification_data(self.tokenize_payment_data)
|
||||
token = tx.token_id
|
||||
self.assertTrue(token, "Should create token for tokenize transction")
|
||||
self.assertFalse(tx.tokenize, "Trasection should be non tokenize after token creation")
|
||||
self.assertEqual(
|
||||
token.provider_ref, f"{self.tokenize_payment_data['customer_id']},{self.tokenize_payment_data['token_id']}",
|
||||
"Should set proper values for provider_ref to get customer_id and token_id from that field"
|
||||
)
|
||||
|
||||
@@ -15,22 +15,6 @@ 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
|
||||
@@ -48,19 +32,20 @@ class TestProcessingFlows(RazorpayCommon, PaymentHttpCommon):
|
||||
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}')
|
||||
def test_webhook_notification_triggers_processing_for_tokenize_transaction(self):
|
||||
""" Test that receiving a valid webhook notification triggers the processing of the
|
||||
notification data for tokenize transaction. """
|
||||
self._create_transaction('redirect', tokenize=True, payment_method_id=self.payment_method_id)
|
||||
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_razorpay.controllers.main.RazorpayController.'
|
||||
'_verify_notification_signature'
|
||||
), 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)
|
||||
) 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_webhook_notification_triggers_signature_check(self):
|
||||
|
||||
@@ -22,6 +22,18 @@
|
||||
password="True"/>
|
||||
</group>
|
||||
</group>
|
||||
<field name="allow_tokenization" position="after">
|
||||
<div invisible="code != 'razorpay' or not allow_tokenization" colspan="2">
|
||||
<a href="https://www.odoo.com/documentation/17.0/applications/finance/payment_providers/razorpay.html#payment-providers-razorpay-recurring-payments"
|
||||
class="btn btn-link"
|
||||
role="button"
|
||||
target="new"
|
||||
>
|
||||
<i class="oi oi-fw o_button_icon oi-arrow-right"/>
|
||||
Enable recurring payments on Razorpay
|
||||
</a>
|
||||
</div>
|
||||
</field>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
|
||||
@@ -2,21 +2,7 @@
|
||||
<odoo>
|
||||
|
||||
<template id="redirect_form">
|
||||
<form action="https://api.razorpay.com/v1/checkout/embedded" method="post">
|
||||
<input type="hidden" name="key_id" t-att-value="key_id"/>
|
||||
<input type="hidden" name="name" t-att-value="name"/>
|
||||
<input type="hidden" name="description" t-att-value="description"/>
|
||||
<input type="hidden" name="image" t-att-value="company_logo"/>
|
||||
<input type="hidden" name="order_id" t-att-value="order_id"/>
|
||||
<input type="hidden" name="amount" t-att-value="amount"/>
|
||||
<input type="hidden" name="currency" t-att-value="currency"/>
|
||||
<input type="hidden" name="prefill[name]" t-att-value="partner_name"/>
|
||||
<input type="hidden" name="prefill[email]" t-att-value="partner_email"/>
|
||||
<input type="hidden" name="prefill[contact]" t-att-value="partner_phone"/>
|
||||
<input type="hidden" name="method" t-att-value="method"/>
|
||||
<input type="hidden" name="callback_url" t-att-value="return_url"/>
|
||||
<input type="hidden" name="cancel_url" t-att-value="return_url"/>
|
||||
</form>
|
||||
TODO: remove in master
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
|
||||
Reference in New Issue
Block a user