diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e9f5b27e..2fb83911 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ exclude: 'node_modules|.git' -default_stages: [commit] +default_stages: [pre-commit] fail_fast: false diff --git a/payments/hooks.py b/payments/hooks.py index caa07d27..55f5beba 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -30,6 +30,9 @@ # include js in doctype views # doctype_js = {"doctype" : "public/js/doctype.js"} +doctype_js = { + "Payment Request": "public/js/payment_request.js", +} # doctype_list_js = {"doctype" : "public/js/doctype_list.js"} # doctype_tree_js = {"doctype" : "public/js/doctype_tree.js"} # doctype_calendar_js = {"doctype" : "public/js/doctype_calendar.js"} @@ -100,13 +103,16 @@ # --------------- # Hook on document methods and events -# doc_events = { -# "*": { -# "on_update": "method", -# "on_cancel": "method", -# "on_trash": "method" -# } -# } +doc_events = { + # "*": { + # "on_update": "method", + # "on_cancel": "method", + # "on_trash": "method" + # } + "Payment Request": { + "on_submit": "payments.payment_gateways.doctype.bankmuscat_settings.bankmuscat_settings.store_custom_name", + } +} # Scheduled Tasks # --------------- @@ -115,6 +121,9 @@ "all": [ "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.capture_payment", ], + "daily": [ + "payments.templates.pages.bankmuscat_checkout.check_payment_status", + ], } # Testing diff --git a/payments/patches.txt b/payments/patches.txt index e69de29b..e92fac3f 100644 --- a/payments/patches.txt +++ b/payments/patches.txt @@ -0,0 +1,4 @@ +[pre_model_sync] + +[post_model_sync] +payments.utils.custom_fields #11 \ No newline at end of file diff --git a/payments/patches/custom_fields.py b/payments/patches/custom_fields.py new file mode 100644 index 00000000..6e69ae55 --- /dev/null +++ b/payments/patches/custom_fields.py @@ -0,0 +1,7 @@ +import frappe + +from payments.utils.utils import make_custom_fields + + +def execute(): + make_custom_fields() diff --git a/payments/payment_gateways/doctype/bankmuscat_settings/__init__.py b/payments/payment_gateways/doctype/bankmuscat_settings/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.js b/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.js new file mode 100644 index 00000000..bccff2a1 --- /dev/null +++ b/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, Frappe Technologies and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("BankMuscat Settings", { +// refresh(frm) { + +// }, +// }); diff --git a/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.json b/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.json new file mode 100644 index 00000000..12f86752 --- /dev/null +++ b/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.json @@ -0,0 +1,64 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:merchant_id", + "creation": "2025-01-16 14:46:37.045855", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "merchant_id", + "access_code", + "working_key", + "base_url" + ], + "fields": [ + { + "fieldname": "merchant_id", + "fieldtype": "Data", + "label": "Merchant ID", + "unique": 1 + }, + { + "fieldname": "access_code", + "fieldtype": "Password", + "label": "Access Code" + }, + { + "fieldname": "working_key", + "fieldtype": "Password", + "label": "Working Key" + }, + { + "description": "https://{sandbox or prod domain}", + "fieldname": "base_url", + "fieldtype": "Data", + "label": "Base URL" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-11-04 14:32:40.445494", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "BankMuscat Settings", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "modified", + "sort_order": "DESC", + "states": [] +} diff --git a/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.py b/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.py new file mode 100644 index 00000000..dabde81f --- /dev/null +++ b/payments/payment_gateways/doctype/bankmuscat_settings/bankmuscat_settings.py @@ -0,0 +1,253 @@ +import frappe +import json +from frappe import _ +from string import Template +from Crypto.Cipher import AES +from frappe.model.document import Document +from payments.utils import create_payment_gateway +from frappe.utils import call_hook_method, get_url +from frappe.integrations.utils import create_request_log +from urllib.parse import parse_qsl + +class BankMuscatSettings(Document): + + supported_currencies = ("OMR", "AED", "USD", "GBP", "EUR", "INR") + + # Validate and create a Payment Gateway entry automatically when BankMuscat Settings is saved + def on_update(self): + try: + if not self.merchant_id: + frappe.throw(_("Merchant ID is required to create a Payment Gateway.")) + + gateway_name = f"BankMuscat-{self.merchant_id}" + if not frappe.db.exists("Payment Gateway", gateway_name): + create_payment_gateway( + gateway_name, + settings="BankMuscat Settings", + controller=self.merchant_id, + ) + frappe.logger().info(f"[BankMuscat] Created new Payment Gateway: {gateway_name}") + else: + frappe.logger().info(f"[BankMuscat] Payment Gateway already exists: {gateway_name}") + + call_hook_method("payment_gateway_enabled", gateway=gateway_name) + except Exception: + error_trace = frappe.get_traceback() + frappe.log_error( + message=error_trace, + title=f"BankMuscat Payment Gateway Creation Failed (Merchant: {self.merchant_id or 'Unknown'})" + ) + + frappe.throw( + _( + "Unable to create or update Payment Gateway for Merchant ID {0}. Please check the Error Log for details." + ).format(self.merchant_id or _("Unknown")), + title=_("Payment Gateway Error"), + ) + + # Validate input and create an Integration Request entry to generate and return the payment URL via email + def get_payment_url(self, **kwargs): + try: + if not kwargs or not isinstance(kwargs, dict): + frappe.throw("Missing or invalid parameters for BankMuscat payment request.") + + required_fields = ["amount", "reference_doctype", "reference_docname", "currency", "payment_gateway"] + missing_fields = [field for field in required_fields if field not in kwargs or not kwargs[field]] + if missing_fields: + frappe.throw(f"Missing required fields: {', '.join(missing_fields)}") + + self.order_id = create_request_log( + data=kwargs, + service_name="BankMuscat", + name=kwargs.get("order_id", "") + ).name + + return get_url(f"bankmuscat_checkout?order_id={self.order_id}") + except Exception: + error_message = frappe.get_traceback() + frappe.log_error(error_message, "BankMuscat get_payment_url Failed") + if getattr(self, "order_id", None): + order_name = getattr(self.order_id, "name", self.order_id) + frappe.db.set_value( + "Integration Request", + order_name, + "error", + error_message[:1000] + ) + frappe.throw("Unable to generate payment URL. Please check the Error Log.") + + def decrypt(self, cipher_text, working_key): + try: + cipher_text = bytes.fromhex(cipher_text) + nonce, ciphertext, tag = cipher_text[: AES.block_size], cipher_text[16:-16], cipher_text[-16:] + cipher = AES.new(working_key.encode(), AES.MODE_GCM, nonce=nonce) + return cipher.decrypt_and_verify(ciphertext, tag) + except Exception: + frappe.log_error(frappe.get_traceback(), "BankMuscat Decryption Failed") + frappe.throw("Unable to decrypt response from BankMuscat.") + + def encrypt(self, plain_text, working_key): + try: + cipher = AES.new(working_key.encode(), AES.MODE_GCM) + ciphertext, tag = cipher.encrypt_and_digest(plain_text.encode()) + encrypted_hex = (cipher.nonce + ciphertext + tag).hex() + return encrypted_hex + except Exception: + frappe.log_error(frappe.get_traceback(), "BankMuscat Encryption Failed") + frappe.throw(_("Unable to encrypt request for BankMuscat. Please check the Error Log.")) + + # Prepare and return the merchant data string required by BankMuscat gateway. + def get_merchant_data(self, **kwargs): + try: + # Base URL for redirect and cancel URLs + base_url = get_url("api/method/payments.templates.pages.bankmuscat_checkout") + + if not base_url: + frappe.throw(_("Unable to generate redirect base URL for BankMuscat checkout.")) + + order_id = (kwargs.get("order_id") or str(getattr(self, "order_id", ""))).replace("-", "") + if not order_id: + frappe.throw(_("Missing 'order_id' in payment data.")) + + amount = kwargs.get("amount") + if not amount: + frappe.throw(_("Missing 'amount' for BankMuscat transaction.")) + + currency = kwargs.get("currency") or "OMR" + if not currency: + frappe.throw(_("Missing 'currency' in payment data.")) + + merchant_data = { + "merchant_id": kwargs.get("merchant_id", str(self.merchant_id)), + "order_id": order_id, + "currency": currency, + "amount": str(amount), + "redirect_url": f"{base_url}.verify_payment_status", + "cancel_url": f"{base_url}.cancel_payment", + "integration_type": kwargs.get("integration_type", "iframe_normal"), + } + + # add optional fields + optional_fields = [ + "language", + "billing_name","billing_address","billing_city","billing_state", + "billing_zip","billing_country","billing_tel","billing_email", + "delivery_name","delivery_address","delivery_city","delivery_state", + "delivery_zip","delivery_country","delivery_tel", + "merchant_param1","merchant_param2","merchant_param3","merchant_param4","merchant_param5", + "promo_code","customer_identifier", + ] + + merchant_data.update({field: kwargs.get(field, "") for field in optional_fields}) + + return "&".join(f"{key}={value}" for key, value in merchant_data.items()) + except Exception as e: + frappe.log_error(frappe.get_traceback(), "BankMuscat: get_merchant_data Failed") + frappe.throw(_("Unable to prepare merchant data for BankMuscat. Please check the Error Log.")) + + # Validate that the provided currency is supported by BankMuscat. + def validate_transaction_currency(self, currency): + if not currency: + frappe.throw(_("Currency is missing")) + if currency not in self.supported_currencies: + frappe.throw( + _( + "BankMuscat does not support transactions in currency {0}. Please select another payment method." + ).format(currency) + ) + + # Validate all mandatory fields required for processing a BankMuscat transaction + def validate_mandatory_values(self, **kwargs): + self.validate_transaction_currency(kwargs.get("currency")) + + amount = kwargs.get("amount") + if not amount: + frappe.throw(_("Amount is missing")) + elif float(amount) <= 0: + frappe.throw(_("Amount must be greater than zero")) + + order_id = kwargs.get("order_id") or getattr(self, "order_id", None) + if not order_id: + frappe.throw(_("Parameter 'order_id' is missing")) + + # Generate the BankMuscat payment page URL and return an auto-submitting HTML form. + def get_payment_page_url(self, **kwargs): + try: + self.validate_mandatory_values(**kwargs) + + merchant_data = self.get_merchant_data(**kwargs) + + working_key = self.get_password("working_key") + + if not working_key: + frappe.throw(_("Missing 'working_key' in BankMuscat Settings.")) + + encrypted_req = self.encrypt(merchant_data, working_key) + + frappe.logger().info(f"[BankMuscat] Encrypted request generated for Order ID: {kwargs.get('order_id')}") + + xscode = self.get_password("access_code") + + base_url = self.base_url + + if not base_url: + frappe.throw(_("Base URL is not configured in BankMuscat Settings.")) + + if not xscode: + frappe.throw(_("Access code is missing in BankMuscat Settings.")) + + action_url = f"{base_url}/transaction.do?command=initiateTransaction" + + html = Template( + """ +
+ """ + ).safe_substitute( + encReq=encrypted_req, + xscode=xscode, + action_url=action_url + ) + + return html + except Exception: + frappe.log_error(frappe.get_traceback(), "BankMuscat get_gateway_url Failed") + frappe.throw( + _("Unable to generate BankMuscat payment form. Please check the Error Log for details.") + ) + +# Gateway controller resolver +def get_gateway_controller(doctype, docname, payment_gateway=None): + if not payment_gateway: + reference_doc = frappe.get_doc(doctype, docname) + payment_gateway = reference_doc.payment_gateway + + return frappe.db.get_value("Payment Gateway", payment_gateway, "gateway_controller") + + +# store custom name without hyphen if Payment Gateway is BankMuscat +def store_custom_name(doc, method=None): + try: + if "BankMuscat" not in (doc.payment_gateway_account or ""): + return + + if not hasattr(doc, "name"): + frappe.throw(_("Document has no name attribute to generate custom_name.")) + + clean_name = doc.name.replace("-", "") + doc.db_set("custom_name", clean_name) + + except Exception: + frappe.log_error( + message=frappe.get_traceback(), + title=f"Failed to set custom_name for Payment Request {getattr(doc, 'name', 'Unknown')}", + ) + frappe.throw( + _("Unable to store custom name for Payment Request {0}. Please check the Error Log.").format( + getattr(doc, "name", "Unknown") + ), + title=_("Custom Name Error"), + ) diff --git a/payments/payment_gateways/doctype/bankmuscat_settings/test_bankmuscat_settings.py b/payments/payment_gateways/doctype/bankmuscat_settings/test_bankmuscat_settings.py new file mode 100644 index 00000000..62672b34 --- /dev/null +++ b/payments/payment_gateways/doctype/bankmuscat_settings/test_bankmuscat_settings.py @@ -0,0 +1,9 @@ +# Copyright (c) 2025, Frappe Technologies and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestBankMuscatSettings(FrappeTestCase): + pass diff --git a/payments/payments/doctype/__init__.py b/payments/payments/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/payments/public/js/payment_request.js b/payments/public/js/payment_request.js new file mode 100644 index 00000000..c6ddd1c5 --- /dev/null +++ b/payments/public/js/payment_request.js @@ -0,0 +1,68 @@ +frappe.ui.form.on("Payment Request", { + refresh(frm) { + const isInward = frm.doc.payment_request_type === "Inward"; + const isInitiated = frm.doc.status === "Initiated"; + const gatewayAccount = frm.doc.payment_gateway_account || ""; + const firstWord = gatewayAccount.split("-")[0].trim().toLowerCase(); + + const isBankMuscat = firstWord === "bankmuscat"; + + frappe.call({ + method: "payments.templates.pages.bankmuscat_checkout.set_payment_entry", + args:{ + "doc_name":frm.doc.name + }, + callback: function(r){ + frm.refresh_field("payment_entry"); + } + }) + + if ( + isInward && + isInitiated && + isBankMuscat && + frm.doc.custom_payment_reference_no + ) { + frm.add_custom_button(__("Check Payment Status"), function () { + frappe.call({ + method: + "payments.templates.pages.bankmuscat_checkout.get_payment_status", + freeze: true, + freeze_message: __("Fetching payment status..."), + args: { + payment_request: frm.doc.name, + }, + callback: function (r) { + if (r.message) { + window.location.reload(); + } + }, + }); + }); + } + + if(frm.doc.status === "Paid" && !frm.doc.payment_entry && frm.doc.response_command){ + frappe.call({ + method:"payments.templates.pages.bankmuscat_checkout.check_roles", + args:{}, + callback:function(r){ + if(r.message){ + frm.add_custom_button(__("Create Payment Entry"), function () { + frappe.call({ + method: "erpnext.accounts.doctype.payment_request.payment_request.make_payment_entry", + args: { docname: frm.doc.name }, + freeze: true, + callback: function (r) { + if (!r.exc) { + var doc = frappe.model.sync(r.message); + frappe.set_route("Form", r.message.doctype, r.message.name); + } + }, + }); + }).addClass("btn-primary"); + } + } + }) + } + }, +}); diff --git a/payments/templates/includes/bankmuscat_checkout.js b/payments/templates/includes/bankmuscat_checkout.js new file mode 100644 index 00000000..fceb2956 --- /dev/null +++ b/payments/templates/includes/bankmuscat_checkout.js @@ -0,0 +1,43 @@ +$(document).ready(function() { + const order_id = new URLSearchParams(window.location.search).get("order_id"); + + if (!order_id) { + console.error("Error: Missing order_id"); + return; + } + + frappe.call({ + method: "payments.templates.pages.bankmuscat_checkout.get_payment_url", + freeze: true, + headers: { + "X-Requested-With": "XMLHttpRequest" + }, + args: { + data: { + "order_id": order_id + } + }, + callback: function(r) { + if (r && r.message && r.message.payment_url) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = r.message.payment_url; + + const form = tempDiv.querySelector('form'); + if (form) { + document.body.appendChild(form); + form.submit(); + } else { + console.error("Error: No form found in payment_url"); + } + } + if (r && r.message && r.message.msg && r.message.url) { + const urlWithMsg = `${r.message.url}?msg=${encodeURIComponent(r.message.msg)}`; + window.location.href = urlWithMsg; + } + + }, + error: function(err) { + console.error("API call failed:", err); + } + }); +}); diff --git a/payments/templates/pages/bankmuscat_checkout.html b/payments/templates/pages/bankmuscat_checkout.html new file mode 100644 index 00000000..8dc66187 --- /dev/null +++ b/payments/templates/pages/bankmuscat_checkout.html @@ -0,0 +1,16 @@ +{% extends "templates/web.html" %} + +{% block title %} Payment {% endblock %} + +{%- block header -%}{% endblock %} + +{% block script %} + +{% endblock %} + +{%- block page_content -%} ++ {{ _("Loading Payment System...") }} +
+ +{% endblock %} diff --git a/payments/templates/pages/bankmuscat_checkout.py b/payments/templates/pages/bankmuscat_checkout.py new file mode 100644 index 00000000..bb43da1f --- /dev/null +++ b/payments/templates/pages/bankmuscat_checkout.py @@ -0,0 +1,611 @@ +import json +import frappe +import requests +from frappe import _ +from urllib.parse import parse_qsl, urlencode +from frappe.utils import flt, get_url, getdate +from frappe.integrations.utils import create_request_log +from payments.payment_gateways.doctype.bankmuscat_settings.bankmuscat_settings import ( + BankMuscatSettings as BankMuscat, + get_gateway_controller +) + +# Check if any previously created Integration Request has status = "Completed"; +# if yes, return the payment success page URL +def check_already_payment_processed(request, reference_doctype, reference_docname): + status = frappe.db.get_value("Integration Request", request, "status") + if status != "Completed": + return None + + params = urlencode({ + "doctype": reference_doctype, + "docname": reference_docname + }) + redirect_url = f"payment-success?{params}" + + return {"payment_url": get_url(redirect_url)} + +# Fetch and return the Bank Muscat payment URL for a valid Integration Request. +@frappe.whitelist(allow_guest=True) +def get_payment_url(data=None): + try: + if isinstance(data, str): + data = frappe.parse_json(data or "{}") + + data = frappe._dict(data or {}) + + if not data.get("order_id"): + frappe.throw(_("Order ID not found in request."), title="Invalid Request") + + integration_request = frappe.db.exists("Integration Request", data.order_id) + if not integration_request: + if not integration_request: + frappe.throw(_("Invalid Order ID. No Integration Request found.")) + + order_data = frappe.db.get_value("Integration Request", integration_request, "data") + if not order_data: + frappe.throw( + _("Integration Request data is missing."), + title=_("Payment Failed") + ) + + order_details = frappe._dict(frappe.parse_json(order_data)) + + condition, msg, status = check_url_usage_status(data.order_id) + + if condition: + if status == "pending": + return {"msg": msg, "url": get_url("/payment-failed")} + if status == "completed": + return {"msg": msg, "url": get_url("/payment-success")} + + reference_doctype = order_details.get("reference_doctype") + + reference_docname = order_details.get("reference_docname") + + redirect_url = check_already_payment_processed( + integration_request, reference_doctype, reference_docname + ) + + if redirect_url: + return redirect_url + + if not (reference_doctype and reference_docname): + frappe.throw( + _("Reference document details are missing."), + title=_("Invalid Request") + ) + + payment_gateway = frappe.db.get_value(reference_doctype, reference_docname, "payment_gateway") + if not payment_gateway: + frappe.throw( + _("Payment Gateway not linked to this transaction."), + title=_("Payment Failed") + ) + + gateway_controller = get_gateway_controller(reference_doctype, reference_docname, payment_gateway) + if not gateway_controller: + frappe.throw( + _("Unable to identify payment gateway controller."), + title=_("Payment Failed") + ) + + gateway_doc = frappe.get_doc("BankMuscat Settings", gateway_controller) + + payment_url = gateway_doc.get_payment_page_url(**order_details) + if not payment_url: + frappe.throw( + _("Unable to generate payment URL. Please try again later."), + title=_("Payment Failed") + ) + + return {"payment_url": payment_url} + + except Exception as e: + frappe.log_error( + title="BankMuscat: Payment URL Generation Failed", + message=frappe.get_traceback(with_context=True), + ) + frappe.throw(e.message) + +# Route the UI page based on the response +def handle_payment_response(data_dict, reference_doctype, reference_docname): + data = frappe._dict(data_dict) + + order_no = data.get("order_id") or data.get("order_no") + doc_name = frappe.get_value("Payment Request", {"custom_name": order_no}) + + if not doc_name: + frappe.throw(f"No Payment Request found for order_no: {order_no}") + + payment_request = frappe.get_doc("Payment Request", doc_name) + + # Save tracking ID if not already saved + if payment_request.status == "Initiated" and not payment_request.custom_payment_reference_no: + payment_request.db_set({ + "transaction_date": getdate(data.get("trans_date")), + "custom_payment_reference_no": data.get("tracking_id"), + "bank_reference_no": data.get("bank_ref_no") + }) + + order_status = data.get("order_status", "").lower() + + try: + if order_status == "success": + msg = _( + "The customer has successfully completed the payment for the requested order. " + "Details: Order ID: {order_id}, Amount: {amount}, Payment Reference: {payment_ref_no}, " + "Bank Reference: {bank_ref_no}, Date: {payment_date}. " + "Please update your records accordingly." + ).format( + order_id=data.get("order_id"), + amount=data.get("amount"), + payment_ref_no=data.get("tracking_id"), + bank_ref_no=data.get("bank_ref_no"), + payment_date=data.get("trans_date") + ) + + frappe.db.set_value( + reference_doctype, + reference_docname, + { + "status": "Paid", + "transaction_status": "The payment has been completed", + "response_command": msg, + } + ) + # payment_entry = payment_request.set_as_paid() + # payment_request.db_set("transaction_status", "The payment has been completed") + + # frappe.db.set_value( + # "Payment Entry", + # payment_entry.name, + # {"reference_no": data.get("bank_ref_no"), "reference_date": getdate(data.get("trans_date"))}, + # ) + + frappe.db.set_value( + "Integration Request", doc_name, {"status": "Completed", "output": json.dumps(data, indent=4)} + ) + + token = frappe.generate_hash(length=32) + + frappe.cache().set_value( + f"payment_success:{token}", + {"doctype": reference_doctype, "docname": reference_docname}, + expires_in_sec=300 + ) + + return redirect_response(f"payment-success?token={token}") + + # return redirect_response("payment-success") + + elif order_status in ("failure", "invalid", "timeout"): + payment_request.db_set("status", "Failed") + payment_request.db_set("transaction_status", "Payment Not Completed") + + frappe.db.set_value( + "Integration Request", doc_name, {"status": "Failed", "error": json.dumps(data, indent=4)} + ) + + return redirect_response("payment-failed") + + elif order_status == "aborted": + try: + payment_request.set_as_cancelled() + payment_request.db_set("transaction_status", "Payment Cancelled") + + frappe.db.set_value( + "Integration Request", + doc_name, + {"status": "Cancelled", "error": json.dumps(data, indent=4)}, + ) + + return redirect_response("payment-cancel") + + except Exception: + frappe.log_error("Error during cancel_payment()", frappe.get_traceback()) + else: + payment_request.db_set("transaction_status", "Waiting for Payment Response") + return redirect_response("payment-processing", reference_doctype, reference_docname) + + except Exception: + frappe.log_error("Error while processing payment response", frappe.get_traceback()) + +# Update the Payment Request and Payment Entry based on the response +def handle_payment_page_response( + payment_request, gateway_controller, data, payment_gateway_account, kwargs=None, integration_request=False +): + if gateway_controller: + gateway_doc = frappe.get_doc("BankMuscat Settings", gateway_controller) + decrypted_data = gateway_doc.decrypt(data.get("encResp"), gateway_doc.get_password("working_key")) + + data_dict = dict(pair.split("=") for pair in decrypted_data.split("&")) + + frappe.log_error("Payment Success Response: ", data_dict) + + if data_dict.get("order_status") == "Success": + try: + if not integration_request: + create_request_log(kwargs, service_name="BankMuscat", name=kwargs.get("order_id", "")) + frappe.db.set_value( + "Integration Request", + data_dict.get("order_id"), + {"status": "Completed", "output": json.dumps(data_dict, indent=4)}, + ) + except Exception: + frappe.log_error("Integration request failed", frappe.get_traceback()) + try: + gateway_account = frappe.get_doc("Payment Gateway Account", payment_gateway_account) + frappe.db.set_value( + "Payment Request", + data_dict.get("order_id"), + { + "payment_gateway_account": gateway_account.name, + "payment_gateway": gateway_account.payment_gateway, + "payment_account": gateway_account.payment_account, + "payment_channel": gateway_account.payment_channel, + "transaction_status": "The payment has been completed", + }, + ) + payment_request.reload() + payment_entry = payment_request.set_as_paid() + + if payment_entry: + frappe.db.set_value( + "Payment Entry", + payment_entry.name, + { + "reference_no": data_dict.get("bank_ref_no"), + "reference_date": getdate(data_dict.get("trans_date")), + }, + ) + except Exception: + frappe.log_error("Error while mark as paid..", frappe.get_traceback()) + + redirect_url = "payment-success" + redirect_url += "?" + urlencode({"doctype": "Payment Request"}) + redirect_url += "&" + urlencode({"docname": payment_request.name}) + + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = get_url(redirect_url) + + else: + try: + frappe.db.set_value( + "Payment Request", + data_dict.get("order_id"), + {"payment_gateway_account": payment_gateway_account}, + ) + payment_request = frappe.get_doc("Payment Request", data_dict.get("order_id")) + payment_request.set_as_failed() + except Exception: + frappe.log_error("Error while mark as failed..", frappe.get_traceback()) + + try: + if not integration_request: + create_request_log( + kwargs, + service_name="BankMuscat", + name=kwargs.get("order_id", ""), + error=json.dumps(data_dict, indent=4), + ) + frappe.db.set_value("Integration Request", data_dict.get("order_id"), {"status": "Failed"}) + except Exception: + frappe.log_error("Integration request failed", frappe.get_traceback()) + + redirect_url = "payment-failed" + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = get_url(redirect_url) + +# Validate response data +@frappe.whitelist(allow_guest=True) +def verify_payment_status(): + data = frappe.form_dict + order_id = frappe.db.get_value("Payment Request", {"custom_name": data.get("orderNo")}) + if order_id and frappe.db.exists("Integration Request", order_id): + order_details = frappe.parse_json(frappe.db.get_value("Integration Request", order_id, "data")) + reference_doctype = order_details.get("reference_doctype") + reference_docname = order_details.get("reference_docname") + if ("reference_doctype" in order_details) and ("reference_docname" in order_details): + payment_gateway = frappe.db.get_value(reference_doctype, reference_docname, "payment_gateway") + if payment_gateway: + gateway_controller = get_gateway_controller( + reference_doctype, reference_docname, payment_gateway + ) + if gateway_controller: + gateway_doc = frappe.get_doc("BankMuscat Settings", gateway_controller) + decrypted_data = gateway_doc.decrypt( + data.get("encResp"), gateway_doc.get_password("working_key") + ) + decrypted_str = decrypted_data.decode("utf-8") + data_dict = dict(pair.split("=") for pair in decrypted_str.split("&")) + return handle_payment_response(data_dict, reference_doctype, reference_docname) + else: + payment_request = frappe.get_doc("Payment Request", order_id) + payment_gateway, payment_gateway_account = frappe.db.get_value( + "Default Gateway Account", + { + "parenttype": "Payment Request", + "parent": payment_request.name, + "company": frappe.db.get_value(reference_doctype, reference_docname, "company"), + "gateway_settings": ["like", "%BankMuscat%"], + }, + ["payment_gateway", "payment_gateway_account"], + ) + + gateway_controller = get_gateway_controller( + reference_doctype, reference_docname, payment_gateway + ) + + return handle_payment_page_response( + payment_request, + gateway_controller, + data, + payment_gateway_account, + kwargs=order_details, + integration_request=True, + ) + + elif order_id: + payment_request = frappe.get_doc("Payment Request", order_id) + request_data = frappe.db.get_value( + payment_request.reference_doctype, + payment_request.reference_name, + ["company", "customer_name"], + as_dict=1, + ) + request_data.update({"company": frappe.defaults.get_defaults().company}) + + kwargs = { + "amount": flt(payment_request.grand_total, payment_request.precision("grand_total")), + "title": request_data.company, + "description": payment_request.subject, + "reference_doctype": "Payment Request", + "reference_docname": payment_request.name, + "payer_email": payment_request.email_to or frappe.session.user, + "payer_name": request_data.customer_name, + "order_id": payment_request.name, + "currency": payment_request.currency, + } + + payment_gateway, payment_gateway_account = frappe.db.get_value( + "Default Gateway Account", + { + "parenttype": "Payment Request", + "parent": payment_request.name, + "company": frappe.db.get_value(payment_request.doctype, payment_request.name, "company"), + "gateway_settings": ["like", "%BankMuscat%"], + }, + ["payment_gateway", "payment_gateway_account"], + ) + + gateway_controller = get_gateway_controller( + payment_request.doctype, payment_request.name, payment_gateway + ) + + return handle_payment_page_response( + payment_request, + gateway_controller, + data, + payment_gateway_account, + kwargs=kwargs, + integration_request=False, + ) + + else: + frappe.respond_as_web_page("Payment Failed", "Order ID not found", http_status_code=404) + + +@frappe.whitelist(allow_guest=True) +def cancel_payment(): + data = frappe.form_dict + doc_name = frappe.get_value("Payment Request", {"custom_name": data.get("orderNo")}) + + if frappe.db.exists("Integration Request", doc_name): + frappe.db.set_value("Integration Request", doc_name, "status", "Cancelled") + try: + payment_request = frappe.get_doc("Payment Request", doc_name) + payment_request.set_as_cancelled() + except Exception: + frappe.log_error("Error while mark as Cancelled..", frappe.get_traceback()) + + return redirect_response("payment-cancel") + + +def redirect_response(page, doctype=None, docname=None): + user = frappe.session.user + + if user != "Guest": + return True + + query_params = {} + + if doctype: + query_params["doctype"] = doctype + if docname: + query_params["docname"] = docname + + url = f"{page}" + if query_params: + url += "?" + urlencode(query_params) + + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = get_url(url) + +# Check payment status daily and update the status +def check_payment_status(): + payment_requests = frappe.get_all( + "Payment Request", + filters={"status": "Initiated"}, + fields=["name", "custom_name", "custom_payment_reference_no", "payment_gateway"], + ) + + filtered_requests = [ + pr + for pr in payment_requests + if pr.custom_payment_reference_no + and pr.payment_gateway + and pr.payment_gateway.startswith("BankMuscat-") + ] + + # Call get_payment_status for each filtered payment request + for pr in filtered_requests: + try: + get_payment_status(pr.name) + except Exception: + frappe.log_error( + title="check_payment_status", + message=f"Error calling get_payment_status for {pr.name}: {frappe.get_traceback()}", + ) + +# Check payment status in Payment Request +@frappe.whitelist() +def get_payment_status(payment_request): + try: + doc = frappe.get_doc("Payment Request", payment_request) + reference_no = doc.custom_payment_reference_no + + if not reference_no or not doc.name: + frappe.throw("Missing order number or reference number") + + # Prepare payload + request_payload = { + "order_id": doc.name, + "reference_no": reference_no, + } + json_data = json.dumps(request_payload) + + # Get Gateway name from account format e.g., "bankmuscat-1234" + gateway_name = doc.payment_gateway.split("-")[1] + bankmuscat_settings = frappe.get_doc("BankMuscat Settings", gateway_name) + + encrypted_data = bankmuscat_settings.encrypt( + json_data, bankmuscat_settings.get_password("working_key") + ) + + access_code = bankmuscat_settings.get_password("access_code") + + payload = { + "enc_request": encrypted_data, + "access_code": access_code, + "request_type": "JSON", + "response_type": "JSON", + "command": "orderStatusTracker", + "version": "1.2", + } + + SMARTPAY_URL = f"{bankmuscat_settings.base_url}/apis/servlet/DoWebTrans?" + + # Make request to SmartPay + response = requests.post(SMARTPAY_URL, data=payload) + parsed_response = dict(parse_qsl(response.text)) + + if parsed_response.get("status") != "0": + frappe.throw("API request failed: " + parsed_response.get("enc_response", "")) + + # Decrypt and parse final response + decrypted_data = bankmuscat_settings.decrypt( + parsed_response["enc_response"], bankmuscat_settings.get_password("working_key") + ) + decrypted_json = json.loads(decrypted_data) + # Payment status handling + return handle_payment_response(decrypted_json, "Payment Request", payment_request) + + except Exception: + frappe.log_error( + title="get_payment_status", message="BankMuscat Payment Status API Error: frappe.get_traceback()" + ) + return {"status": "error", "message": frappe.get_traceback()} + +def check_url_usage_status(id): + try: + from frappe.utils import now_datetime + integration_request = frappe.db.get_value( + "Integration Request", + id, + ["status", "url_access_time"], + as_dict=True + ) + + status = integration_request.status + last_access_time = integration_request.url_access_time + + if status != "Completed" and not last_access_time: + frappe.db.set_value( + "Integration Request", + id, + "url_access_time", + now_datetime() + ) + return False, None, None + + elif status != "Completed" and last_access_time: + + from datetime import datetime + if isinstance(last_access_time, str): + last_access_time = datetime.fromisoformat(last_access_time) + + current_time = now_datetime() + diff_minutes = (current_time - last_access_time).total_seconds() / 60 + + if diff_minutes < 45: + remaining = 45 - int(diff_minutes) + + message = _( + "This payment link will be available again after {0} minutes. Please try again later." + ).format(remaining) + + return True, message, "pending" + + return False, None, None + + elif status == "Completed": + message = ( + "This payment has already been completed successfully. No further action is required." + ) + return True, None, "completed" + + except Exception as e: + frappe.log_error( + title="BankMuscat URL Access Check Failed", + message=frappe.get_traceback() + ) + return True, "Oops! Something didn’t work as expected. Please contact our Al Farsi service team for help.", "pending" + +@frappe.whitelist() +def set_payment_entry(doc_name): + + exist_doc = frappe.db.get_value( + "Payment Entry", + { "reference_no": doc_name }, + "name" + ) + + if exist_doc: + frappe.db.set_value( + "Payment Request", + doc_name, + "payment_entry", + exist_doc + ) + + return True + +@frappe.whitelist() +def check_roles(): + roles = frappe.get_all( + "DocPerm", + filters={ + "parent": "Payment Entry", + "permlevel": 0, + "create": 1 + }, + fields=["role"] + ) + + user_roles = frappe.get_roles(frappe.session.user) + + has_permission = any(r["role"] in user_roles for r in roles) + + return has_permission + diff --git a/payments/templates/pages/payment-failed.html b/payments/templates/pages/payment-failed.html index 8f416b56..0f517605 100644 --- a/payments/templates/pages/payment-failed.html +++ b/payments/templates/pages/payment-failed.html @@ -8,7 +8,7 @@ {{ _("Payment Failed") }} -{{ _("Your payment has failed.") }}
+{{ frappe.form_dict.msg or "Your payment has failed." }}
diff --git a/payments/templates/pages/payment-processing.html b/payments/templates/pages/payment-processing.html new file mode 100644 index 00000000..d294b443 --- /dev/null +++ b/payments/templates/pages/payment-processing.html @@ -0,0 +1,29 @@ +{% extends "templates/web.html" %} + +{% block title %}{{ _("Payment Initiated") }}{% endblock %} + +{% block page_content %} +{{ _("Your payment has been successfully initiated and is currently being processed.") }}
+{{ _("Sometimes it takes a little while for the bank to confirm the transaction.") }}
+{{ _("No worries — the status will update automatically once we receive a response.") }}
+{{ _("You can safely return now, and we’ll keep tracking the update in the background.") }}
+ +