From 3f5da1d9eadf1d22f4906d04d833d83d857d998c Mon Sep 17 00:00:00 2001 From: Harsh Modi Date: Thu, 22 Jun 2023 14:37:20 +0530 Subject: [PATCH] [ADD] pos_paytm: add paytm pos terminal payment method This commit adds the module pos_paytm to add a new payment method of payTM to point_of_sale. task - 3377992 closes odoo/odoo#134960 X-original-commit: 2630c3e5f451df3b86f4906f7dfa3e5e1f5ec199 Signed-off-by: Harsh Modi (hamo) --- .../models/res_config_settings.py | 1 + .../views/res_config_settings_views.xml | 3 + addons/pos_paytm/__init__.py | 1 + addons/pos_paytm/__manifest__.py | 32 ++++ addons/pos_paytm/models/__init__.py | 2 + addons/pos_paytm/models/pos_payment.py | 32 ++++ addons/pos_paytm/models/pos_payment_method.py | 166 ++++++++++++++++++ .../pos_paytm/static/src/js/PaymentScreen.js | 19 ++ addons/pos_paytm/static/src/js/model.js | 5 + .../pos_paytm/static/src/js/payment_paytm.js | 160 +++++++++++++++++ addons/pos_paytm/static/src/js/pos_paytm.js | 33 ++++ .../views/pos_payment_method_views.xml | 19 ++ addons/pos_paytm/views/pos_payment_views.xml | 18 ++ 13 files changed, 491 insertions(+) create mode 100644 addons/pos_paytm/__init__.py create mode 100644 addons/pos_paytm/__manifest__.py create mode 100644 addons/pos_paytm/models/__init__.py create mode 100644 addons/pos_paytm/models/pos_payment.py create mode 100644 addons/pos_paytm/models/pos_payment_method.py create mode 100644 addons/pos_paytm/static/src/js/PaymentScreen.js create mode 100644 addons/pos_paytm/static/src/js/model.js create mode 100644 addons/pos_paytm/static/src/js/payment_paytm.js create mode 100644 addons/pos_paytm/static/src/js/pos_paytm.js create mode 100644 addons/pos_paytm/views/pos_payment_method_views.xml create mode 100644 addons/pos_paytm/views/pos_payment_views.xml diff --git a/addons/point_of_sale/models/res_config_settings.py b/addons/point_of_sale/models/res_config_settings.py index 26581e20f84..2e1552111cb 100644 --- a/addons/point_of_sale/models/res_config_settings.py +++ b/addons/point_of_sale/models/res_config_settings.py @@ -35,6 +35,7 @@ class ResConfigSettings(models.TransientModel): module_pos_adyen = fields.Boolean(string="Adyen Payment Terminal", help="The transactions are processed by Adyen. Set your Adyen credentials on the related payment method.") module_pos_stripe = fields.Boolean(string="Stripe Payment Terminal", help="The transactions are processed by Stripe. Set your Stripe credentials on the related payment method.") module_pos_six = fields.Boolean(string="Six Payment Terminal", help="The transactions are processed by Six. Set the IP address of the terminal on the related payment method.") + module_pos_paytm = fields.Boolean(string="PayTM Payment Terminal", help="The transactions are processed by PayTM. Set your PayTM credentials on the related payment method.") module_pos_preparation_display = fields.Boolean(string="Preparation Display", help="Show orders on the preparation display screen.") update_stock_quantities = fields.Selection(related="company_id.point_of_sale_update_stock_quantities", readonly=False) account_default_pos_receivable_account_id = fields.Many2one(string='Default Account Receivable (PoS)', related='company_id.account_default_pos_receivable_account_id', readonly=False) diff --git a/addons/point_of_sale/views/res_config_settings_views.xml b/addons/point_of_sale/views/res_config_settings_views.xml index 9b69b0d9011..ad0f31ef461 100644 --- a/addons/point_of_sale/views/res_config_settings_views.xml +++ b/addons/point_of_sale/views/res_config_settings_views.xml @@ -300,6 +300,9 @@ + + + diff --git a/addons/pos_paytm/__init__.py b/addons/pos_paytm/__init__.py new file mode 100644 index 00000000000..0650744f6bc --- /dev/null +++ b/addons/pos_paytm/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/addons/pos_paytm/__manifest__.py b/addons/pos_paytm/__manifest__.py new file mode 100644 index 00000000000..d167e48b249 --- /dev/null +++ b/addons/pos_paytm/__manifest__.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. +{ + 'name': 'POS PayTM', + 'version': '1.0', + 'category': 'Sales/Point of Sale', + 'sequence': 6, + 'summary': 'Integrate your POS with a PayTM payment terminal', + 'description': """ +Allow Paytm POS payments +============================== + +This module allows customers to pay for their orders with debit/credit +cards and UPI. The transactions are processed by Paytm POS. A Paytm 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_paytm/static/**/*', + ], + }, + 'license': 'LGPL-3', +} diff --git a/addons/pos_paytm/models/__init__.py b/addons/pos_paytm/models/__init__.py new file mode 100644 index 00000000000..5efcd71f600 --- /dev/null +++ b/addons/pos_paytm/models/__init__.py @@ -0,0 +1,2 @@ +from . import pos_payment_method +from . import pos_payment diff --git a/addons/pos_paytm/models/pos_payment.py b/addons/pos_paytm/models/pos_payment.py new file mode 100644 index 00000000000..0a0ca9786fe --- /dev/null +++ b/addons/pos_paytm/models/pos_payment.py @@ -0,0 +1,32 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, models, fields + +class PosPayment(models.Model): + + _inherit = "pos.payment" + + paytm_authcode = fields.Char('Paytm APPR Code') + paytm_issuer_card_no = fields.Char('Paytm Issue Mask Card No.') + paytm_issuer_bank = fields.Char('Paytm Issuer Bank') + paytm_payment_method = fields.Char('Paytm Payment Method') + paytm_reference_no = fields.Char('Paytm Merchant Reference No.') + paytm_card_scheme = fields.Char('Paytm Card Scheme') + +class PosOrder(models.Model): + + _inherit = 'pos.order' + + @api.model + def _payment_fields(self, order, ui_paymentline): + fields = super()._payment_fields(order, ui_paymentline) + fields.update({ + 'paytm_authcode': ui_paymentline.get('paytm_authcode'), + 'paytm_issuer_card_no': ui_paymentline.get('paytm_issuer_card_no'), + 'paytm_issuer_bank': ui_paymentline.get('paytm_issuer_bank'), + 'paytm_payment_method':ui_paymentline.get('paytm_payment_method'), + 'paytm_reference_no': ui_paymentline.get('paytm_reference_no'), + 'paytm_card_scheme': ui_paymentline.get('paytm_card_scheme') + }) + return fields diff --git a/addons/pos_paytm/models/pos_payment_method.py b/addons/pos_paytm/models/pos_payment_method.py new file mode 100644 index 00000000000..dd48b127dac --- /dev/null +++ b/addons/pos_paytm/models/pos_payment_method.py @@ -0,0 +1,166 @@ +# coding: utf-8 +# Part of Odoo. See LICENSE file for full copyright and licensing details. +import base64 +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +import hashlib +import logging +import requests +import secrets +import string + +from odoo.exceptions import UserError +from odoo import fields, models, api, _ +from datetime import datetime +from dateutil import tz + +_logger = logging.getLogger(__name__) +REQUEST_TIMEOUT = 30 +iv = b'@@@@&&&&####$$$$' + +class PosPaymentMethod(models.Model): + _inherit = 'pos.payment.method' + + paytm_tid = fields.Char(string='PayTM Terminal ID') + channel_id = fields.Char(string='PayTM Channel ID', default='EDC') + accept_payment = fields.Selection(selection=[('auto', 'Automatically'), ('manual', 'Manually')], default='auto') + allowed_payment_modes = fields.Selection(selection=[('all', 'All'), ('card', 'Card'), ('qr', 'QR')], default='all') + paytm_mid = fields.Char(string="PayTM Merchant ID") + paytm_merchant_key = fields.Char(string="PayTM Merchant API Key") + paytm_test_mode = fields.Boolean(string="PayTM Test Mode", default=False) + + def _get_payment_terminal_selection(self): + return super()._get_payment_terminal_selection() + [('paytm', 'PayTM')] + + def _paytm_make_request(self, url, payload=None): + """ Make a request to PayTM API. + + :param str url: The url to be reached by the request. + :param dict payload: The payload of the request. + :return The JSON-formatted content of the response. + :rtype: dict + """ + try: + if self.paytm_test_mode: + api_url = 'https://securegw-stage.paytm.in/ecr/' + else: + api_url = 'https://securegw-edc.paytm.in/ecr/' + response = requests.post(api_url+url, json=payload, timeout=REQUEST_TIMEOUT) + response.raise_for_status() + except (requests.exceptions.Timeout, requests.exceptions.RequestException) as error: + _logger.warning("Cannot connect with PayTM. Error: %s", error) + return {'error': '%s' % error} + res_json = response.json() + if res_json.get('body'): + return res_json['body'] + default_error_msg = _('Something went wrong with paytm request. Please try later.') + error = res_json.get('error') or default_error_msg + return {'error': '%s' % error} + + def paytm_make_payment_request(self, amount, transaction_id, reference_id, timestamp): + body = self._paytm_get_request_body(transaction_id, reference_id, timestamp) + body['transactionAmount'] = str(int(amount)) + if self.accept_payment == 'auto': + body['autoAccept'] = 'True' + body['paymentMode'] = self.allowed_payment_modes.upper() + head = self._paytm_get_request_head(body) + head_error = head.get('error') + if head_error: + return {'error': '%s' % head_error} + merchantExtendedInfo = {'paymentMode': self.allowed_payment_modes.upper()} + if self.accept_payment == 'auto': + merchantExtendedInfo['autoAccept'] = 'True' + body['merchantExtendedInfo'] = merchantExtendedInfo + payload = {'head': head, 'body': body} + response = self._paytm_make_request('payment/request', payload=payload) + result_code = response.get('resultInfo', {}).get('resultCode') + if result_code == 'A': + return response['resultInfo'] + elif result_code == 'F': + return {'error': "%s" % response['resultInfo'].get('resultMsg', _('paytm transaction request declined'))} + default_error_msg = _('makePaymentRequest expected resultCode not found in the response') + error = response.get('error') or default_error_msg + return {'error': '%s' % error} + + def paytm_fetch_payment_status(self, transaction_id, reference_id, timestamp): + body = self._paytm_get_request_body(transaction_id, reference_id, timestamp) + head = self._paytm_get_request_head(body) + head_error = head.get('error') and head + if head_error: + return head_error + payload = {'head': head, 'body': body} + response = self._paytm_make_request('V2/payment/status', payload=payload) + result_code = response.get('resultInfo', {}).get('resultCode') + if result_code == 'S': + # Since we don't want to send extra data on RPC call + # Only sending essential data when the transaction is successful + data = response['resultInfo'] + data.update({ + 'authCode': response.get('authCode'), + 'issuerMaskCardNo': response.get('issuerMaskCardNo'), + 'issuingBankName': response.get('issuingBankName'), + 'payMethod': response.get('payMethod'), + 'cardType': response.get('cardType'), + 'cardScheme': response.get('cardScheme'), + 'merchantReferenceNo': response.get('merchantReferenceNo'), + 'merchantTransactionId': response.get('merchantTransactionId'), + 'transactionDateTime': response.get('transactionDateTime'), + }) + return data + elif result_code == 'F': + return {'error': "%s" % response['resultInfo'].get('resultMsg', _('paytm transaction failure'))} + elif result_code == 'P': + return response['resultInfo'] + default_error_msg = _('paymentFetchRequest expected resultCode not found in the response') + error = response.get('error') or default_error_msg + return {'error': '%s' % error} + + def _paytm_generate_signature(self, params_dict, key): + params_list = [] + for k in sorted(params_dict.keys()): + value = params_dict[k] + if value is None or params_dict[k].lower() == "null": + value = "" + params_list.append(str(value)) + salt = ''.join(secrets.choice(string.ascii_letters + string.digits) for _ in range(4)) + params_list.append(salt) + params_with_salt = '|'.join(params_list) + hashed_params = hashlib.sha256(params_with_salt.encode()) + hashed_params_with_salt = hashed_params.hexdigest() + salt + padding = 12 #the padding value is a constant + padded_hashed_params_with_salt = bytes(hashed_params_with_salt + padding * chr(padding), 'utf-8') + try: + cipher = Cipher(algorithms.AES(key.encode()), modes.CBC(iv)) + encryptor = cipher.encryptor() + encrypted_hashed_params = encryptor.update(padded_hashed_params_with_salt) + encryptor.finalize() + return base64.b64encode(encrypted_hashed_params).decode("UTF-8") + except ValueError as error: + _logger.warning("Cannot generate PayTM signature. Error: %s", error) + return {'error': '%s' % error} + + def _paytm_get_request_body(self, transaction_id, reference_id, timestamp): + + time = datetime.fromtimestamp(timestamp).astimezone(tz=tz.gettz('Asia/Kolkata')).strftime("%Y-%m-%d %H:%M:%S") + return { + 'paytmMid': self.paytm_mid, + 'paytmTid': self.paytm_tid, + 'transactionDateTime': time, + 'merchantTransactionId': transaction_id, + 'merchantReferenceNo': reference_id, + } + + def _paytm_get_request_head(self, body): + paytm_signature = self._paytm_generate_signature(body, self.paytm_merchant_key) + error = isinstance(paytm_signature, dict) and paytm_signature.get('error') + if error: + return {'error': '%s' % error} + return { + 'requestTimeStamp' : body["transactionDateTime"], + 'channelId' : self.channel_id, + 'checksum' : paytm_signature, + } + + @api.constrains('use_payment_terminal') + def _check_paytm_terminal(self): + for record in self: + if record.use_payment_terminal == 'paytm' and record.company_id.currency_id.name != 'INR': + raise UserError(_('This Payment Terminal is only valid for INR Currency')) diff --git a/addons/pos_paytm/static/src/js/PaymentScreen.js b/addons/pos_paytm/static/src/js/PaymentScreen.js new file mode 100644 index 00000000000..54fe7ef5ce6 --- /dev/null +++ b/addons/pos_paytm/static/src/js/PaymentScreen.js @@ -0,0 +1,19 @@ +/** @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(() => { + const pendingPaymentLine = this.currentOrder.paymentlines.find( + paymentLine => paymentLine.payment_method.use_payment_terminal === 'paytm' && (!paymentLine.is_done() && paymentLine.get_payment_status() !== 'pending') + ); + if (pendingPaymentLine) { + pendingPaymentLine.set_payment_status('force_done'); + } + }); + } +}); diff --git a/addons/pos_paytm/static/src/js/model.js b/addons/pos_paytm/static/src/js/model.js new file mode 100644 index 00000000000..9a0d3bba21b --- /dev/null +++ b/addons/pos_paytm/static/src/js/model.js @@ -0,0 +1,5 @@ +/** @odoo-module */ +import { register_payment_method } from "@point_of_sale/app/store/pos_store"; +import { PaymentPaytm } from '@pos_paytm/js/payment_paytm'; + + register_payment_method('paytm', PaymentPaytm); diff --git a/addons/pos_paytm/static/src/js/payment_paytm.js b/addons/pos_paytm/static/src/js/payment_paytm.js new file mode 100644 index 00000000000..71f591807ff --- /dev/null +++ b/addons/pos_paytm/static/src/js/payment_paytm.js @@ -0,0 +1,160 @@ +/** @odoo-module */ + +import { PaymentInterface } from "@point_of_sale/app/payment/payment_interface"; +import { ErrorPopup } from "@point_of_sale/app/errors/popups/error_popup"; + +const REQUEST_TIMEOUT = 5000 + +export class PaymentPaytm extends PaymentInterface { + + /** + * @Override + * @param { string } cid + * @returns Promise + */ + async send_payment_request(cid) { + await super.send_payment_request(...arguments); + const paymentLine = this.pos.get_order()?.selected_paymentline; + const order = this.pos?.selectedOrder; + let retry = this._retryCountUtility(order.uid) + let transactionId = order.name.replace(' ', '').replaceAll('-', '').toUpperCase(); + if (retry > 0) { + transactionId = transactionId.concat('retry', retry); + } + const transactionAmount = paymentLine.amount*100; + const timeStamp = Math.floor(Date.now() / 1000); + + // Preparing Unique Random Reference Id + const referencePrefix = this.pos.config?.name.replace(/\s/g, "").slice(0,4) + const referenceId = referencePrefix.concat(Math.floor(Math.random()*1000000000)) + const response = await this.makePaymentRequest(transactionAmount, transactionId, referenceId, timeStamp); + if (!response) { + paymentLine.set_payment_status('force_done'); + this._incrementRetry(order.uid); + return false + } + paymentLine.set_payment_status('waitingCard'); + const pollResponse = await this.pollPayment(transactionId, referenceId, timeStamp); + if (pollResponse) { + let retry_remove = true + this._retryCountUtility(order.uid, retry_remove) + return true; + } + else { + this._incrementRetry(order.uid); + return false; + } + } + + /** + * @Override + * @param {} order + * @param { string } cid + * @returns Promise + */ + async send_payment_cancel(order, cid) { + await super.send_payment_cancel(...arguments); + const paymentLine = this.pos.get_order()?.selected_paymentline; + paymentLine.set_payment_status('retry'); + this._incrementRetry(order.uid); + clearTimeout(this.pollTimeout); + return true; + } + + /** + * @param { string } transactionId + * @param { string } referenceId + * @param { datetime } timestamp + * @returns Promise + */ + async pollPayment(transactionId, referenceId, timestamp) { + const fetchPaymentStatus = async (resolve, reject) => { + const paymentLine = this.pos.get_order()?.selected_paymentline; + if (!paymentLine || paymentLine.payment_status == 'retry') { + return false; + } + try { + const data = await this.env.services.orm.silent.call( + 'pos.payment.method', + 'paytm_fetch_payment_status', + [[this.payment_method.id], transactionId, referenceId, timestamp], + ); + if (data?.error) { + throw data?.error; + } + let resultCode = data?.resultCode; + if (resultCode === 'S' && data?.merchantReferenceNo != referenceId){ + throw this.env._t("Reference number mismatched"); + } else if (resultCode === 'S') { + paymentLine.paytm_authcode = data?.authCode; + paymentLine.paytm_issuer_card_no = data?.issuerMaskCardNo; + paymentLine.paytm_issuer_bank = data?.issuingBankName; + paymentLine.paytm_payment_method = data?.payMethod; + paymentLine.card_type = data?.cardType; + paymentLine.paytm_card_scheme = data?.cardScheme; + paymentLine.paytm_reference_no = data?.merchantReferenceNo; + paymentLine.transactionId = data?.merchantTransactionId; + paymentLine.payment_date = data?.transactionDateTime; + return resolve(data); + } else { + this.pollTimeout = setTimeout(fetchPaymentStatus, REQUEST_TIMEOUT, resolve, reject); + } + } catch (error) { + const order = this.pos.selectedOrder; + this._incrementRetry(order.uid); + paymentLine.set_payment_status('force_done'); + this._showError(error, 'paytmFetchPaymentStatus'); + return resolve(false) + }; + }; + return new Promise(fetchPaymentStatus); + } + /** + * @param { float } amount + * @param { string } transactionId + * @param { string } referenceId + * @param { datetime } timestamp + * @returns Promise + */ + async makePaymentRequest(amount, transactionId, referenceId, timestamp) { + try { + const data = await this.env.services.orm.silent.call( + 'pos.payment.method', + 'paytm_make_payment_request', + [[this.payment_method.id], amount, transactionId, referenceId, timestamp], + ); + if (data?.error) { + throw data.error; + } + return data; + } catch (error) { + this._showError(error, 'paytmMakePaymentRequest'); + return false; + }; + } + + // --------------------------------------------------------------------------- + // Private methods + // --------------------------------------------------------------------------- + + _retryCountUtility(uid, remove=false) { + if(remove){ + localStorage.removeItem(uid); + } + else{ + return localStorage.getItem(uid) || (localStorage.setItem(uid, 0) && 0); + } + } + + _incrementRetry(uid) { + let retry = localStorage.getItem(uid); + localStorage.setItem(uid, ++retry); + } + _showError(error_msg, title) { + var self = this; + self.env.services.popup.add(ErrorPopup, { + title: title || self.env._t('PayTM Error'), + body: error_msg, + }); + } +} diff --git a/addons/pos_paytm/static/src/js/pos_paytm.js b/addons/pos_paytm/static/src/js/pos_paytm.js new file mode 100644 index 00000000000..aa7e22f83ec --- /dev/null +++ b/addons/pos_paytm/static/src/js/pos_paytm.js @@ -0,0 +1,33 @@ +/** @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 === 'paytm'){ + this.paytm_authcode = json.paytm_authcode; + this.paytm_issuer_card_no = json.paytm_issuer_card_no; + this.paytm_issuer_bank = json.paytm_issuer_bank; + this.paytm_payment_method = json.paytm_payment_method; + this.paytm_reference_no = json.paytm_reference_no; + this.paytm_card_scheme = json.paytm_card_scheme; + } + }, + export_as_JSON() { + const result = super.export_as_JSON(...arguments); + if(result && this.payment_method?.use_payment_terminal === 'paytm'){ + return Object.assign(result, { + paytm_authcode: this.paytm_authcode, + paytm_issuer_card_no: this.paytm_issuer_card_no, + paytm_issuer_bank: this.paytm_issuer_bank, + paytm_payment_method: this.paytm_payment_method, + paytm_reference_no: this.paytm_reference_no, + paytm_card_scheme: this.paytm_card_scheme, + }); + } + return result + }, +}); diff --git a/addons/pos_paytm/views/pos_payment_method_views.xml b/addons/pos_paytm/views/pos_payment_method_views.xml new file mode 100644 index 00000000000..2899b93fa3e --- /dev/null +++ b/addons/pos_paytm/views/pos_payment_method_views.xml @@ -0,0 +1,19 @@ + + + + pos.payment.method.form.inherit.paytm + pos.payment.method + + + + + + + + + + + + + + diff --git a/addons/pos_paytm/views/pos_payment_views.xml b/addons/pos_paytm/views/pos_payment_views.xml new file mode 100644 index 00000000000..ffa70a0f871 --- /dev/null +++ b/addons/pos_paytm/views/pos_payment_views.xml @@ -0,0 +1,18 @@ + + + + pos.payment.form + pos.payment + + + + + + + + + + + + +