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
+
+
+
+
+
+
+
+
+
+
+
+
+