[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:
committed by
Antoine Vandevenne (anv)
co-authored by
Antoine Vandevenne
Prakash Prajapati
parent
3dad4b3cb1
commit
08a7c779cb
@@ -349,6 +349,23 @@
|
||||
])]"/>
|
||||
</record>
|
||||
|
||||
<record id="payment_acquirer_razorpay" model="payment.acquirer">
|
||||
<field name="name">Razorpay</field>
|
||||
<field name="display_as">Credit & 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 |
@@ -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.
|
||||
@@ -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')
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user