[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:
kcv-odoo
2023-12-24 07:01:14 +00:00
parent 50e8e08183
commit 513be97b2f
14 changed files with 452 additions and 134 deletions
+1 -1
View File
@@ -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"
+18 -13
View File
@@ -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.
+5
View File
@@ -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',
+6
View File
@@ -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 = {
+2 -20
View File
@@ -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();
}
},
});
},
});
+11
View File
@@ -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>