diff --git a/addons/pos_razorpay/__init__.py b/addons/pos_razorpay/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/addons/pos_razorpay/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/pos_razorpay/__manifest__.py b/addons/pos_razorpay/__manifest__.py new file mode 100644 index 00000000000..3ca393bd98c --- /dev/null +++ b/addons/pos_razorpay/__manifest__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'POS Razorpay', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'sequence': 6, + 'summary': 'Integrate your POS with a Razorpay payment terminal', + 'description': """ +Allow Razorpay POS payments +============================== + +This module allows customers to pay for their orders with debit/credit +cards and UPI. The transactions are processed by Razorpay POS. A Razorpay merchant account is necessary. It allows the +following: + +* Fast payment by just swiping/scanning a credit/debit card or a QR code while on the payment screen +* Supported cards: Visa, MasterCard, Rupay, UPI + """, + 'data': [ + 'views/pos_payment_method_views.xml', + 'views/pos_payment_views.xml', + ], + 'depends': ['point_of_sale'], + 'installable': True, + 'assets': { + 'point_of_sale._assets_pos': [ + 'pos_razorpay/static/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/pos_razorpay/data/neutralize.sql b/addons/pos_razorpay/data/neutralize.sql new file mode 100644 index 00000000000..35efdae1ed5 --- /dev/null +++ b/addons/pos_razorpay/data/neutralize.sql @@ -0,0 +1,3 @@ +UPDATE pos_payment_method +SET razorpay_test_mode = true, + razorpay_api_key = 'dummykey00012345'; diff --git a/addons/pos_razorpay/models/__init__.py b/addons/pos_razorpay/models/__init__.py new file mode 100644 index 00000000000..5efcd71f600 --- /dev/null +++ b/addons/pos_razorpay/models/__init__.py @@ -0,0 +1,2 @@ +from . import pos_payment_method +from . import pos_payment diff --git a/addons/pos_razorpay/models/pos_payment.py b/addons/pos_razorpay/models/pos_payment.py new file mode 100644 index 00000000000..ee50e2d8c8b --- /dev/null +++ b/addons/pos_razorpay/models/pos_payment.py @@ -0,0 +1,33 @@ +from odoo import api, models, fields + +class PosPayment(models.Model): + + _inherit = "pos.payment" + + razorpay_authcode = fields.Char('Razorpay APPR Code') + razorpay_issuer_card_no = fields.Char('Razorpay Issue Card No Last 4 digits') + razorpay_issuer_bank = fields.Char('Razorpay Issuer Bank') + razorpay_payment_method = fields.Char('Razorpay Payment Method') + razorpay_reference_no = fields.Char('Razorpay Merchant Reference No.') + razorpay_reverse_ref_no = fields.Char('Razorpay Reverse Reference No.') + razorpay_card_scheme = fields.Char('Razorpay Card Scheme') + razorpay_card_owner_name = fields.Char('Razorpay Card Owner Name') + +class PosOrder(models.Model): + + _inherit = 'pos.order' + + @api.model + def _payment_fields(self, order, ui_paymentline): + payment_fields = super()._payment_fields(order, ui_paymentline) + payment_fields.update({ + 'razorpay_authcode': ui_paymentline.get('razorpay_authcode'), + 'razorpay_issuer_card_no': ui_paymentline.get('razorpay_issuer_card_no'), + 'razorpay_issuer_bank': ui_paymentline.get('razorpay_issuer_bank'), + 'razorpay_payment_method': ui_paymentline.get('razorpay_payment_method'), + 'razorpay_reference_no': ui_paymentline.get('razorpay_reference_no'), + 'razorpay_reverse_ref_no': ui_paymentline.get('razorpay_reverse_ref_no'), + 'razorpay_card_scheme': ui_paymentline.get('razorpay_card_scheme'), + 'razorpay_card_owner_name': ui_paymentline.get('razorpay_card_owner_name'), + }) + return payment_fields diff --git a/addons/pos_razorpay/models/pos_payment_method.py b/addons/pos_razorpay/models/pos_payment_method.py new file mode 100644 index 00000000000..e0d62e27b10 --- /dev/null +++ b/addons/pos_razorpay/models/pos_payment_method.py @@ -0,0 +1,80 @@ +from odoo.exceptions import UserError +from odoo import fields, models, api, _ + +from .razorpay_pos_request import RazorpayPosRequest + +class PosPaymentMethod(models.Model): + _inherit = 'pos.payment.method' + + razorpay_tid = fields.Char(string='Razorpay Device Serial No', help="Device Serial No \n ex: 7000012300") + razorpay_allowed_payment_modes = fields.Selection(selection=[('all', 'All'), ('card', 'Card'), ('upi', 'UPI'), ('bharatqr', 'BHARATQR')], default='all', help="Choose allow payment mode: \n All/Card/UPI or QR") + razorpay_username = fields.Char(string="Razorpay Username", help="Username(Device Login) \n ex: 1234500121") + razorpay_api_key = fields.Char(string="Razorpay API Key", help="Used when connecting to Razorpay: https://razorpay.com/docs/payments/dashboard/account-settings/api-keys/") + razorpay_test_mode = fields.Boolean(string="Razorpay Test Mode", default=False, help="Turn it on when in Test Mode") + + def _get_payment_terminal_selection(self): + return super()._get_payment_terminal_selection() + [('razorpay', 'Razorpay')] + + def razorpay_make_payment_request(self, data): + razorpay = RazorpayPosRequest(self) + body = razorpay._razorpay_get_payment_request_body(payment_mode=True) + body.update({ + 'amount': data.get('amount'), + 'externalRefNumber': data.get('referenceId') + }) + response = razorpay._call_razorpay(endpoint="pay", payload=body) + if response.get('success') and not response.get('errorCode'): + return { + 'success': True, + 'p2pRequestId': str(response.get('p2pRequestId')) + } + default_error_msg = _('Razorpay POS payment request expected errorCode not found in the response') + error = response.get('errorMessage') or default_error_msg + return {'error': str(error)} + + def razorpay_fetch_payment_status(self, data): + razorpay = RazorpayPosRequest(self) + body = razorpay._razorpay_get_payment_status_request_body() + body.update({'origP2pRequestId': data.get('p2pRequestId')}) + response = razorpay._call_razorpay(endpoint="status", payload=body) + if response.get('success') and not response.get('errorCode'): + payment_status = response.get('status') + payment_messageCode = response.get('messageCode') + if payment_status == 'AUTHORIZED' and payment_messageCode == 'P2P_DEVICE_TXN_DONE': + return { + 'status': response.get('status'), + 'authCode': response.get('authCode'), + 'cardLastFourDigit': response.get('cardLastFourDigit'), + 'externalRefNumber': response.get('externalRefNumber'), + 'reverseReferenceNumber': response.get('reverseReferenceNumber'), + 'txnId': response.get('txnId'), + 'paymentMode': response.get('paymentMode'), + 'paymentCardType': response.get('paymentCardType'), + 'paymentCardBrand': response.get('paymentCardBrand'), + 'nameOnCard': response.get('nameOnCard'), + 'acquirerCode': response.get('acquirerCode'), + 'createdTime': response.get('createdTime'), + } + elif payment_status == 'FAILED' or payment_messageCode == 'P2P_DEVICE_CANCELED': + return {'error': str(response.get('message', _('Razorpay POS transaction failed')))} + elif payment_messageCode in ['P2P_DEVICE_RECEIVED', 'P2P_DEVICE_SENT', 'P2P_STATUS_QUEUED']: + return {'status': payment_messageCode.split("_")[-1]} + default_error_msg = _('Razorpay POS payment status request expected errorCode not found in the response') + error = response.get('errorMessage') or default_error_msg + return {'error': str(error)} + + def razorpay_cancel_payment_request(self, data): + razorpay = RazorpayPosRequest(self) + body = razorpay._razorpay_get_payment_request_body(payment_mode=False) + body.update({'origP2pRequestId': data.get('p2pRequestId')}) + response = razorpay._call_razorpay(endpoint="cancel", payload=body) + if response.get('success') and not response.get('errorCode'): + return {'error': _('Razorpay POS transaction canceled successfully')} + default_error_msg = _('Razorpay POS payment cancel request expected errorCode not found in the response') + errorMessage = response.get('errorMessage') or default_error_msg + return {'errorMessage': str(errorMessage)} + + @api.constrains('use_payment_terminal') + def _check_razorpay_terminal(self): + if any(record.use_payment_terminal == 'razorpay' and record.company_id.currency_id.name != 'INR' for record in self): + raise UserError(_('This Payment Terminal is only valid for INR Currency')) diff --git a/addons/pos_razorpay/models/razorpay_pos_request.py b/addons/pos_razorpay/models/razorpay_pos_request.py new file mode 100644 index 00000000000..e0f9e12ee22 --- /dev/null +++ b/addons/pos_razorpay/models/razorpay_pos_request.py @@ -0,0 +1,61 @@ +import requests +import logging + +from odoo import _ +REQUEST_TIMEOUT = 10 + +_logger = logging.getLogger(__name__) + +class RazorpayPosRequest: + def __init__(self, payment_method): + self.razorpay_test_mode = payment_method.razorpay_test_mode + self.razorpay_api_key = payment_method.razorpay_api_key + self.razorpay_username = payment_method.razorpay_username + self.razorpay_tid = payment_method.razorpay_tid + self.razorpay_allowed_payment_modes = payment_method.razorpay_allowed_payment_modes + self.payment_method = payment_method + self.session = requests.Session() + + def _razorpay_get_endpoint(self): + if self.razorpay_test_mode: + return 'https://demo.ezetap.com/api/3.0/p2padapter/' + return 'https://www.ezetap.com/api/3.0/p2padapter/' + + def _call_razorpay(self, endpoint, payload): + """ Make a request to Razorpay POS API. + + :param str endpoint: The endpoint to be reached by the request. + :param dict payload: The payload of the request. + :return The JSON-formatted content of the response. + :rtype: dict + """ + endpoint = f"{self._razorpay_get_endpoint()}{endpoint}" + request_timeout = self.payment_method.env["ir.config_parameter"].sudo().get_param("pos_razorpay.timeout", REQUEST_TIMEOUT) + try: + response = self.session.post(endpoint, json=payload, timeout=request_timeout) + response.raise_for_status() + res_json = response.json() + except requests.exceptions.RequestException as error: + _logger.warning("Cannot connect with Razorpay POS. Error: %s", error) + return {'errorMessage': str(error)} + except ValueError as error: + _logger.warning("Cannot decode response json. Error: %s", error) + return {'errorMessage': _("Cannot decode Razorpay POS response")} + return res_json + + def _razorpay_get_payment_request_body(self, payment_mode=True): + request_parameters = { + 'pushTo': { + "deviceId": f"{self.razorpay_tid}|ezetap_android", + }, + } + if payment_mode: + request_parameters.update({'mode': self.razorpay_allowed_payment_modes.upper()}) + request_parameters.update(self._razorpay_get_payment_status_request_body()) + return request_parameters + + def _razorpay_get_payment_status_request_body(self): + return { + 'username': self.razorpay_username, + 'appKey': self.razorpay_api_key, + } diff --git a/addons/pos_razorpay/static/src/app/payment_razorpay.js b/addons/pos_razorpay/static/src/app/payment_razorpay.js new file mode 100644 index 00000000000..a2883740a90 --- /dev/null +++ b/addons/pos_razorpay/static/src/app/payment_razorpay.js @@ -0,0 +1,190 @@ +/** @odoo-module */ + +import { _t } from "@web/core/l10n/translation"; +import { PaymentInterface } from "@point_of_sale/app/payment/payment_interface"; +import { AlertDialog } from "@web/core/confirmation_dialog/confirmation_dialog"; + +const REQUEST_TIMEOUT = 10000; + +export class PaymentRazorpay extends PaymentInterface { + setup() { + super.setup(...arguments); + this.pollingTimeout = null; + this.queued = false; + this.payment_stopped = false; + } + + send_payment_request(cid) { + super.send_payment_request(cid); + return this._process_razorpay(cid); + } + + pending_razorpay_line() { + return this.pos.getPendingPaymentLine("razorpay"); + } + + send_payment_cancel(order, cid) { + super.send_payment_cancel(order, cid); + return this._razorpay_cancel(); + } + + _call_razorpay(data, action) { + return this.env.services.orm.silent + .call("pos.payment.method", + action, + [[this.payment_method.id], data] + ) + .catch(this._handle_odoo_connection_failure.bind(this)); + } + + _handle_odoo_connection_failure(data = {}) { + // handle timeout + const line = this.pending_razorpay_line(); + if (line) { + line.set_payment_status("retry"); + } + this._showError( + _t( + "Could not connect to the Odoo server, please check your internet connection and try again." + ) + ); + + return Promise.reject(data); // prevent subsequent onFullFilled's from being called + } + + /** + * This method handles the response that comes from Razorpay + * when we make a request for payment/cancel. + */ + _razorpay_handle_response(response) { + const line = this.pending_razorpay_line(); + if (response.error) { + line.set_payment_status('force_done'); + this.payment_stopped ? this._showError(_t("Transaction failed due to inactivity")) : this._showError(response.error); + this.pollingTimeout ? clearTimeout(this.pollingTimeout) : ''; + this._removePaymentHandler(['p2pRequestId', 'referenceId']); + return Promise.resolve(false); + } + line.set_payment_status("waitingCard"); + localStorage.setItem("p2pRequestId", response.p2pRequestId); + return this._waitForPaymentConfirmation(); + } + + _razorpay_cancel() { + const data = {'p2pRequestId': localStorage.getItem('p2pRequestId')}; + return this._call_razorpay(data, 'razorpay_cancel_payment_request').then((data) => { + // This proficiently tackles scenarios where payment initiation is in progress and close to the completion phase + if (data.errorMessage) { + this._showError(data.errorMessage); + return Promise.resolve(false); + } + this._razorpay_handle_response(data); + return Promise.resolve(true); + }); + } + + _process_razorpay(cid) { + const order = this.pos.get_order(); + const line = order.paymentlines.find((paymentLine) => paymentLine.cid === cid); + + if (line.amount < 0) { + this._showError(_t("Cannot process transactions with negative amount.")); + return Promise.resolve(); + } + + const orderId = order.name.replace(" ", "").replaceAll("-", "").toUpperCase(); + const referencePrefix = this.pos.config.name.replace(/\s/g, "").slice(0, 4); + localStorage.setItem("referenceId", referencePrefix + "/" + orderId + "/" + crypto.randomUUID().replaceAll("-", "")); + const data = { + 'amount': line.amount, + 'referenceId': localStorage.getItem('referenceId'), + }; + return this._call_razorpay(data, 'razorpay_make_payment_request').then((data) => { + return this._razorpay_handle_response(data); + }); + } + + /** + * Polling + * This method calls and handles the razorpay status response + * calls every 10 sec until payment is not resolved. + */ + + async _waitForPaymentConfirmation() { + const paymentLine = this.pos.get_order()?.selected_paymentline; + if (!paymentLine || paymentLine.payment_status == "retry") { + return false; + } + const data = {'p2pRequestId': localStorage.getItem('p2pRequestId')}; + this._stop_pending_payment().then(() => this.payment_stopped = true); + const razorpayFetchPaymentStatus = async (resolve, reject) => { + + //Clear previous timeout before setting a new one + clearTimeout(this.pollingTimeout); + + //Within 90 seconds, inactivity will result in transaction cancellation and payment termination. + if (this.payment_stopped) { + this._razorpay_cancel().then(() => { + paymentLine.set_payment_status("force_done"); + this.payment_stopped = false; + }); + return resolve(false); + } + + const response = await this._call_razorpay(data, 'razorpay_fetch_payment_status'); + if (response.error) { + return this._razorpay_handle_response(response); + } + + const resultCode = response?.status; + + if (resultCode === "QUEUED" && this.queued === false) { + this._showError(_t("Payment has been queued. You may choose to wait for the payment to initiate on terminal or proceed to cancel this transaction")); + this.queued = true; + } + if (resultCode === "AUTHORIZED" && response?.externalRefNumber !== localStorage.getItem('referenceId')) { + return this._razorpay_handle_response({'error': _t("Reference number mismatched")}); + } else if (resultCode === "AUTHORIZED") { + paymentLine.razorpay_authcode = response?.authCode; + paymentLine.razorpay_issuer_card_no = response?.cardLastFourDigit; + paymentLine.razorpay_issuer_bank = response?.acquirerCode; + paymentLine.razorpay_payment_method = response?.paymentMode; + paymentLine.card_type = response?.paymentCardType; + paymentLine.razorpay_card_scheme = response?.paymentCardBrand; + paymentLine.razorpay_card_owner_name = response?.nameOnCard; + paymentLine.razorpay_reference_no = response?.externalRefNumber; + paymentLine.razorpay_reverse_ref_no = response?.reverseReferenceNumber; + paymentLine.transactionId = response?.txnId; + paymentLine.payment_date = response?.createdTime; + this._removePaymentHandler(['p2pRequestId', 'referenceId']); + return resolve(response); + } else { + this.pollingTimeout = setTimeout( + razorpayFetchPaymentStatus, + REQUEST_TIMEOUT, + resolve, + reject + ); + } + }; + return new Promise(razorpayFetchPaymentStatus); + } + + _stop_pending_payment() { + return new Promise(resolve => setTimeout(resolve, 90000)); + } + + _removePaymentHandler(payment_data) { + payment_data.forEach((data) => { + localStorage.removeItem(data); + }) + this.queued = false; + } + + _showError(error_msg, title) { + this.env.services.dialog.add(AlertDialog, { + title: title || _t("Razorpay Error"), + body: error_msg, + }); + } +} diff --git a/addons/pos_razorpay/static/src/app/pos_razorpay.js b/addons/pos_razorpay/static/src/app/pos_razorpay.js new file mode 100644 index 00000000000..64987a2b4ec --- /dev/null +++ b/addons/pos_razorpay/static/src/app/pos_razorpay.js @@ -0,0 +1,37 @@ +/** @odoo-module */ + +import { Payment } from "@point_of_sale/app/store/models"; +import { patch } from "@web/core/utils/patch"; + + +patch(Payment.prototype, { + init_from_JSON(json) { + super.init_from_JSON(...arguments); + if(this.payment_method?.use_payment_terminal === 'razorpay'){ + this.razorpay_authcode = json.razorpay_authcode; + this.razorpay_issuer_card_no = json.razorpay_issuer_card_no; + this.razorpay_issuer_bank = json.razorpay_issuer_bank; + this.razorpay_payment_method = json.razorpay_payment_method; + this.razorpay_reference_no = json.razorpay_reference_no; + this.razorpay_reverse_ref_no = json.razorpay_reverse_ref_no; + this.razorpay_card_scheme = json.razorpay_card_scheme; + this.razorpay_card_owner_name = json.razorpay_card_owner_name; + } + }, + export_as_JSON() { + const result = super.export_as_JSON(...arguments); + if(result && this.payment_method?.use_payment_terminal === 'razorpay'){ + return Object.assign(result, { + razorpay_authcode: this.razorpay_authcode, + razorpay_issuer_card_no: this.razorpay_issuer_card_no, + razorpay_issuer_bank: this.razorpay_issuer_bank, + razorpay_payment_method: this.razorpay_payment_method, + razorpay_reference_no: this.razorpay_reference_no, + razorpay_reverse_ref_no: this.razorpay_reverse_ref_no, + razorpay_card_scheme: this.razorpay_card_scheme, + razorpay_card_owner_name: this.razorpay_card_owner_name, + }); + } + return result + }, +}); diff --git a/addons/pos_razorpay/static/src/components/payment_screen/payment_screen.js b/addons/pos_razorpay/static/src/components/payment_screen/payment_screen.js new file mode 100644 index 00000000000..0cdafc690cd --- /dev/null +++ b/addons/pos_razorpay/static/src/components/payment_screen/payment_screen.js @@ -0,0 +1,24 @@ +/** @odoo-module */ + +import { PaymentScreen } from "@point_of_sale/app/screens/payment_screen/payment_screen"; +import { patch } from "@web/core/utils/patch"; +import { onMounted } from "@odoo/owl"; + +patch(PaymentScreen.prototype, { + setup() { + super.setup(...arguments); + onMounted(async() => { + const pendingPaymentLine = this.currentOrder.paymentlines.find( + paymentLine => paymentLine.payment_method.use_payment_terminal === 'razorpay' && (!paymentLine.is_done() && paymentLine.get_payment_status() !== 'pending') + ); + if (pendingPaymentLine) { + const payment_status = await pendingPaymentLine.payment_method.payment_terminal._waitForPaymentConfirmation(); + if (payment_status?.status === "AUTHORIZED") { + pendingPaymentLine.set_payment_status("done"); + } else { + pendingPaymentLine.set_payment_status("force_done"); + } + } + }); + } +}); diff --git a/addons/pos_razorpay/static/src/overrides/models.js b/addons/pos_razorpay/static/src/overrides/models.js new file mode 100644 index 00000000000..7c34ca4a1b8 --- /dev/null +++ b/addons/pos_razorpay/static/src/overrides/models.js @@ -0,0 +1,5 @@ +/** @odoo-module */ +import { register_payment_method } from "@point_of_sale/app/store/pos_store"; +import { PaymentRazorpay } from '@pos_razorpay/app/payment_razorpay'; + +register_payment_method('razorpay', PaymentRazorpay); diff --git a/addons/pos_razorpay/views/pos_payment_method_views.xml b/addons/pos_razorpay/views/pos_payment_method_views.xml new file mode 100644 index 00000000000..214307c0b81 --- /dev/null +++ b/addons/pos_razorpay/views/pos_payment_method_views.xml @@ -0,0 +1,17 @@ + + + + pos.payment.method.form.inherit.razorpay + pos.payment.method + + + + + + + + + + + + diff --git a/addons/pos_razorpay/views/pos_payment_views.xml b/addons/pos_razorpay/views/pos_payment_views.xml new file mode 100644 index 00000000000..43cbca5d23f --- /dev/null +++ b/addons/pos_razorpay/views/pos_payment_views.xml @@ -0,0 +1,19 @@ + + + + pos.payment.form + pos.payment + + + + + + + + + + + + + +