[ADD] payment_razorpay: integrate with Razorpay's "Payment" gateway

See README for technical details.

task-2800823

Part-of: odoo/odoo#92848
Co-authored-by: Antoine Vandevenne (anv) <anv@odoo.com>
Co-authored-by: Prakash Prajapati <ppr@odoo.com>
This commit is contained in:
Barad Mahendra
2022-09-01 19:16:27 +02:00
committed by Antoine Vandevenne (anv)
co-authored by Antoine Vandevenne Prakash Prajapati
parent 3dad4b3cb1
commit 08a7c779cb
23 changed files with 1051 additions and 0 deletions
@@ -349,6 +349,23 @@
])]"/>
</record>
<record id="payment_acquirer_razorpay" model="payment.acquirer">
<field name="name">Razorpay</field>
<field name="display_as">Credit &amp; Debit Card, UPI (Powered by Razorpay)</field>
<field name="image_128" type="base64" file="payment_razorpay/static/description/icon.png"/>
<field name="module_id" ref="base.module_payment_razorpay"/>
<!-- https://razorpay.com/docs/payments/payment-methods/#supported-payment-methods -->
<field name="payment_icon_ids"
eval="[(6, 0, [
ref('payment.payment_icon_cc_maestro'),
ref('payment.payment_icon_cc_mastercard'),
ref('payment.payment_icon_cc_rupay'),
ref('payment.payment_icon_cc_diners_club_intl'),
ref('payment.payment_icon_cc_american_express'),
ref('payment.payment_icon_cc_visa')
])]"/>
</record>
<record id="payment_acquirer_sepa_direct_debit" model="payment.acquirer">
<field name="name">SEPA Direct Debit</field>
<field name="sequence">20</field>
@@ -37,6 +37,12 @@
<field name="image" type="base64" file="payment/static/img/paypal.png"/>
</record>
<record id="payment_icon_cc_rupay" model="payment.icon">
<field name="sequence">65</field>
<field name="name">Rupay</field>
<field name="image" type="base64" file="payment/static/img/rupay.png"/>
</record>
<record id="payment_icon_apple_pay" model="payment.icon">
<field name="sequence">70</field>
<field name="name">Apple Pay</field>
Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

+36
View File
@@ -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.
+10
View File
@@ -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')
+19
View File
@@ -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',
}
+32
View File
@@ -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',
]
@@ -0,0 +1,3 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import main
+107
View File
@@ -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()
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="payment.payment_acquirer_razorpay" model="payment.acquirer">
<field name="provider">razorpay</field>
<field name="redirect_form_view_id" ref="redirect_form"/>
</record>
<record id="payment_method_razorpay" model="account.payment.method">
<field name="name">Razorpay</field>
<field name="code">razorpay</field>
<field name="payment_type">inbound</field>
</record>
</odoo>
@@ -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
@@ -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
@@ -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',
])
@@ -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/<id>/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/<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 != '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.acquirer_id._razorpay_make_request(
f'payments/{self.acquirer_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 != '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)
)
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,27 @@
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<mask id="mask0_10_362" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="70" height="70">
<path d="M4 0H65C69 0 70 1 70 5V65C70 69 69 70 65 70H4C1 70 0 69 0 65V5C0 1 1 0 4 0Z" fill="white"/>
</mask>
<g mask="url(#mask0_10_362)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 0H70V70H0V0Z" fill="url(#paint0_linear_10_362)"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 1H65C67.667 1 69.333 1.667 70 3V0H0V3C0.667 1.667 2 1 4 1Z" fill="white" fill-opacity="0.383"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M4 69H65C67.667 69 69.333 68 70 66V70H0V66C0.667 68 2 69 4 69Z" fill="black" fill-opacity="0.383"/>
</g>
<path opacity="0.324" fill-rule="evenodd" clip-rule="evenodd" d="M4 69C2 69 0 68 0 65V39.5C0 39.5 20 25.5 21.5 24.5L31 22.703H37.25L48 23L51.576 24.968L51.34 46.978L39.224 69H4Z" fill="#393939"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.5744 26.4129L20.4692 30.496L26.805 26.3918L22.6515 41.8793H26.8754L33 19" fill="#465C68"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.7459 35.3745L13 41.8793H21.6378L25.1718 28.6445L14.7529 35.3675" fill="#465C68"/>
<path d="M32.8154 31L31.0904 37.5H49.036V47.212C49.0363 47.3024 49.0008 47.3892 48.9373 47.4534C48.8737 47.5177 48.7874 47.5542 48.697 47.555H22.303C22.116 47.555 21.964 47.4 21.964 47.212V45H19.2568L19.25 47.555C19.25 49.069 20.464 50.297 21.964 50.297H49.036C50.537 50.297 51.75 49.069 51.75 47.555V27.445C51.75 25.931 50.536 24.703 49.036 24.703H34.4865L33.7588 27.445H48.697C48.7874 27.4458 48.8737 27.4823 48.9373 27.5466C49.0008 27.6108 49.0363 27.6976 49.036 27.788V31H32.8154Z" fill="#465C68"/>
<path d="M14.423 55.642H15.459V58.474H16.219V55.642H17.256V55H14.423V55.642Z" fill="#465C68"/>
<path d="M18.492 55H17.422V58.474H18.133V56.036H18.143L18.991 58.474H19.577L20.424 56.012H20.434V58.474H21.145V55H20.075L19.311 57.389H19.301L18.492 55Z" fill="#465C68"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.5744 24.4129L20.4692 28.496L26.805 24.3918L22.6515 39.8793H26.8754L33 17" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.7459 33.3745L13 39.8793H21.6378L25.1718 26.6445L14.7529 33.3675" fill="white"/>
<path d="M32.8154 29L31.0904 35.5H49.036V45.212C49.0363 45.3024 49.0008 45.3892 48.9373 45.4534C48.8737 45.5177 48.7874 45.5542 48.697 45.555H22.303C22.116 45.555 21.964 45.4 21.964 45.212V43H19.2568L19.25 45.555C19.25 47.069 20.464 48.297 21.964 48.297H49.036C50.537 48.297 51.75 47.069 51.75 45.555V25.445C51.75 23.931 50.536 22.703 49.036 22.703H34.4865L33.7588 25.445H48.697C48.7874 25.4458 48.8737 25.4823 48.9373 25.5466C49.0008 25.6108 49.0363 25.6976 49.036 25.788V29H32.8154Z" fill="white"/>
<path d="M14.423 53.642H15.459V56.474H16.219V53.642H17.256V53H14.423V53.642Z" fill="white"/>
<path d="M18.492 53H17.422V56.474H18.133V54.036H18.143L18.991 56.474H19.577L20.424 54.012H20.434V56.474H21.145V53H20.075L19.311 55.389H19.301L18.492 53Z" fill="white"/>
<defs>
<linearGradient id="paint0_linear_10_362" x1="70" y1="0" x2="0" y2="70" gradientUnits="userSpaceOnUse">
<stop stop-color="#94B6C8"/>
<stop offset="1" stop-color="#6A9EBA"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -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
+43
View File
@@ -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,
},
},
}
@@ -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)
@@ -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')
@@ -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,
)
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<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="callback_url" t-att-value="return_url"/>
<input type="hidden" name="cancel_url" t-att-value="return_url"/>
</form>
</template>
</odoo>
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="payment_acquirer_form_razorpay" model="ir.ui.view">
<field name="name">Razorpay Acquirer Form</field>
<field name="model">payment.acquirer</field>
<field name="inherit_id" ref="payment.payment_acquirer_form"/>
<field name="arch" type="xml">
<group name="acquirer" position='inside'>
<group name="razorpay_credentials"
attrs="{'invisible': [('provider', '!=', 'razorpay')]}">
<field name="razorpay_key_id"
string="Key Id"
attrs="{'required': [('provider', '=', 'razorpay'), ('state', '!=', 'disabled')]}"/>
<field name="razorpay_key_secret"
string="Key Secret"
attrs="{'required': [('provider', '=', 'razorpay'), ('state', '!=', 'disabled')]}"
password="True"/>
<field name="razorpay_webhook_secret"
string="Webhook Secret"
attrs="{'required': [('provider', '=', 'razorpay'), ('state', '!=', 'disabled')]}"
password="True"/>
</group>
</group>
</field>
</record>
</odoo>