From deee4e5983df6179c6ff1dc602720340e6988da7 Mon Sep 17 00:00:00 2001 From: badal8381 Date: Thu, 12 Feb 2026 11:54:25 +0000 Subject: [PATCH 01/16] refactor: replace GrayQuest API Log with Integration Request and sanitize payload fields - Remove custom GrayQuest API Log DocType and api_client module - Use Frappe built-in Integration Request for API call logging - Add log_request and make_request methods to GrayQuestSettings - Strip whitespace from all email fields in API payloads - Route customer_mobile through _clean_mobile_number in all payment flows - Use response.ok instead of hardcoded status code checks --- .../doctype/grayquest_api_log/__init__.py | 2 - .../grayquest_api_log/grayquest_api_log.json | 192 ------------------ .../grayquest_api_log/grayquest_api_log.py | 125 ------------ .../test_grayquest_api_log.py | 23 --- .../grayquest_settings/grayquest_settings.py | 131 +++++++++--- grayquest/utils/api_client.py | 82 -------- grayquest/utils/utils.py | 20 +- 7 files changed, 112 insertions(+), 463 deletions(-) delete mode 100644 grayquest/grayquest/doctype/grayquest_api_log/__init__.py delete mode 100644 grayquest/grayquest/doctype/grayquest_api_log/grayquest_api_log.json delete mode 100644 grayquest/grayquest/doctype/grayquest_api_log/grayquest_api_log.py delete mode 100644 grayquest/grayquest/doctype/grayquest_api_log/test_grayquest_api_log.py delete mode 100644 grayquest/utils/api_client.py diff --git a/grayquest/grayquest/doctype/grayquest_api_log/__init__.py b/grayquest/grayquest/doctype/grayquest_api_log/__init__.py deleted file mode 100644 index a4e649f..0000000 --- a/grayquest/grayquest/doctype/grayquest_api_log/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2025, Hybrowlabs Technologies and contributors -# For license information, please see license.txt diff --git a/grayquest/grayquest/doctype/grayquest_api_log/grayquest_api_log.json b/grayquest/grayquest/doctype/grayquest_api_log/grayquest_api_log.json deleted file mode 100644 index ada32ff..0000000 --- a/grayquest/grayquest/doctype/grayquest_api_log/grayquest_api_log.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "actions": [], - "allow_rename": 1, - "autoname": "format:GQ-API-LOG-{####}", - "creation": "2025-01-30 10:00:00.000000", - "doctype": "DocType", - "engine": "InnoDB", - "field_order": [ - "request_details_section", - "request_id", - "timestamp", - "service", - "http_method", - "full_url", - "column_break_request", - "status_code", - "execution_time_ms", - "is_success", - "error_category", - "section_break_request_data", - "request_headers", - "request_payload", - "column_break_response", - "response_body", - "curl_command_section", - "curl_command", - "error_details_section", - "error_details" - ], - "fields": [ - { - "fieldname": "request_details_section", - "fieldtype": "Section Break", - "label": "Request Details" - }, - { - "fieldname": "request_id", - "fieldtype": "Data", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Request ID", - "read_only": 1, - "reqd": 1, - "unique": 1 - }, - { - "fieldname": "timestamp", - "fieldtype": "Datetime", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Timestamp", - "read_only": 1, - "reqd": 1 - }, - { - "fieldname": "service", - "fieldtype": "Data", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Service", - "read_only": 1 - }, - { - "fieldname": "http_method", - "fieldtype": "Select", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "HTTP Method", - "options": "GET\nPOST\nPUT\nPATCH\nDELETE", - "read_only": 1 - }, - { - "fieldname": "full_url", - "fieldtype": "Small Text", - "label": "Full URL", - "read_only": 1 - }, - { - "fieldname": "column_break_request", - "fieldtype": "Column Break" - }, - { - "fieldname": "status_code", - "fieldtype": "Int", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Status Code", - "read_only": 1 - }, - { - "fieldname": "execution_time_ms", - "fieldtype": "Int", - "label": "Execution Time (ms)", - "read_only": 1 - }, - { - "fieldname": "is_success", - "fieldtype": "Check", - "in_list_view": 1, - "in_standard_filter": 1, - "label": "Is Success", - "read_only": 1 - }, - { - "fieldname": "error_category", - "fieldtype": "Select", - "in_standard_filter": 1, - "label": "Error Category", - "options": "\nTimeout\nConnection Error\nValidation Error\nAuthentication Error\nAuthorization Error\nNot Found\nRate Limit\nServer Error\nUnexpected Error", - "read_only": 1 - }, - { - "fieldname": "section_break_request_data", - "fieldtype": "Section Break", - "label": "Request Data" - }, - { - "fieldname": "request_headers", - "fieldtype": "Code", - "label": "Request Headers", - "options": "JSON", - "read_only": 1 - }, - { - "fieldname": "request_payload", - "fieldtype": "Code", - "label": "Request Payload", - "options": "JSON", - "read_only": 1 - }, - { - "fieldname": "column_break_response", - "fieldtype": "Column Break" - }, - { - "fieldname": "response_body", - "fieldtype": "Code", - "label": "Response Body", - "options": "JSON", - "read_only": 1 - }, - { - "fieldname": "curl_command_section", - "fieldtype": "Section Break", - "label": "cURL Command" - }, - { - "fieldname": "curl_command", - "fieldtype": "Code", - "label": "cURL Command", - "options": "Shell", - "read_only": 1 - }, - { - "fieldname": "error_details_section", - "fieldtype": "Section Break", - "label": "Error Details" - }, - { - "fieldname": "error_details", - "fieldtype": "Long Text", - "label": "Error Details", - "read_only": 1 - } - ], - "index_web_pages_for_search": 1, - "links": [], - "modified": "2025-01-30 10:00:00.000000", - "modified_by": "Administrator", - "module": "Grayquest", - "name": "GrayQuest API Log", - "naming_rule": "Expression", - "owner": "Administrator", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "states": [], - "track_changes": 1 -} diff --git a/grayquest/grayquest/doctype/grayquest_api_log/grayquest_api_log.py b/grayquest/grayquest/doctype/grayquest_api_log/grayquest_api_log.py deleted file mode 100644 index 4774e17..0000000 --- a/grayquest/grayquest/doctype/grayquest_api_log/grayquest_api_log.py +++ /dev/null @@ -1,125 +0,0 @@ -# Copyright (c) 2025, Hybrowlabs Technologies and contributors -# For license information, please see license.txt - -import json - -import frappe -from frappe.model.document import Document - - -class GrayQuestAPILog(Document): - # begin: auto-generated types - # This code is auto-generated. Do not modify anything in this block. - - from typing import TYPE_CHECKING - - if TYPE_CHECKING: - from frappe.types import DF - - curl_command: DF.Code | None - error_category: DF.Literal["", "Timeout", "Connection Error", "Validation Error", "Authentication Error", "Authorization Error", "Not Found", "Rate Limit", "Server Error", "Unexpected Error"] - error_details: DF.LongText | None - execution_time_ms: DF.Int - full_url: DF.SmallText | None - http_method: DF.Literal["GET", "POST", "PUT", "PATCH", "DELETE"] - is_success: DF.Check - request_headers: DF.Code | None - request_id: DF.Data - request_payload: DF.Code | None - response_body: DF.Code | None - service: DF.Data | None - status_code: DF.Int - timestamp: DF.Datetime - # end: auto-generated types - - pass - - -def create_api_log( - request_id: str, - service: str, - http_method: str, - full_url: str, - request_headers: dict | None = None, - request_payload: dict | None = None, - status_code: int | None = None, - response_body: str | None = None, - execution_time_ms: int | None = None, - is_success: bool = False, - error_category: str | None = None, - error_details: str | None = None, -) -> str | None: - """Create a new GrayQuest API Log entry.""" - try: - curl_command = _generate_curl_command(http_method, full_url, request_headers, request_payload) - log_doc = frappe.get_doc({ - "doctype": "GrayQuest API Log", - "request_id": request_id, - "timestamp": frappe.utils.now_datetime(), - "service": service, - "http_method": http_method, - "full_url": full_url, - "request_headers": json.dumps(_sanitize_headers(request_headers), indent=2) if request_headers else None, - "request_payload": json.dumps(_sanitize_payload(request_payload), indent=2) if request_payload else None, - "status_code": status_code, - "response_body": response_body or None, - "execution_time_ms": execution_time_ms, - "is_success": 1 if is_success else 0, - "error_category": error_category, - "error_details": error_details, - "curl_command": curl_command, - }) - log_doc.insert(ignore_permissions=True) - frappe.db.commit() - return log_doc.name - except Exception as e: - frappe.log_error( - f"Failed to create GrayQuest API log for request_id: {request_id}. Error: {e!s}", - "GrayQuest API Log Creation Error" - ) - return None - - -def _generate_curl_command(http_method: str, full_url: str, headers: dict | None = None, payload: dict | None = None) -> str: - """Generate a cURL command equivalent to the API request.""" - sensitive_header_keys = ["authorization", "gq-api-key", "api-key", "apikey", "x-api-key"] - curl_parts = ["curl", "-X", http_method] - - if headers: - for key, value in headers.items(): - if key.lower() in sensitive_header_keys: - value = "***REDACTED***" - curl_parts.append(f'-H "{key}: {value}"') - - if payload and http_method != "GET": - payload_str = json.dumps(_sanitize_payload(payload), indent=2).replace('"', '\\"') - curl_parts.append(f'-d "{payload_str}"') - - curl_parts.append(f'"{full_url}"') - return " \\\n ".join(curl_parts) - - -SENSITIVE_HEADER_KEYS = ["authorization", "gq-api-key", "api-key", "apikey", "x-api-key", "token"] -SENSITIVE_PAYLOAD_KEYS = ["key", "hash", "salt", "password", "secret", "token", "api_key", "client_secret"] - - -def _sanitize_headers(headers: dict | None) -> dict: - """Remove sensitive information from headers before logging.""" - if not headers: - return {} - sanitized = headers.copy() - for key in sanitized: - if key.lower() in SENSITIVE_HEADER_KEYS: - sanitized[key] = "***REDACTED***" - return sanitized - - -def _sanitize_payload(payload: dict | None) -> dict: - """Remove sensitive information from payload before logging.""" - if not payload or not isinstance(payload, dict): - return {} - sanitized = payload.copy() - for key in sanitized: - if key.lower() in SENSITIVE_PAYLOAD_KEYS: - sanitized[key] = "***REDACTED***" - return sanitized diff --git a/grayquest/grayquest/doctype/grayquest_api_log/test_grayquest_api_log.py b/grayquest/grayquest/doctype/grayquest_api_log/test_grayquest_api_log.py deleted file mode 100644 index 18bb2a8..0000000 --- a/grayquest/grayquest/doctype/grayquest_api_log/test_grayquest_api_log.py +++ /dev/null @@ -1,23 +0,0 @@ -# Copyright (c) 2025, Hybrowlabs Technologies and Contributors -# See license.txt - -# import frappe -from frappe.tests import IntegrationTestCase, UnitTestCase - - -class UnitTestGrayquestApiLog(UnitTestCase): - """ - Unit tests for GrayQuestAPILog. - Use this class for testing individual functions and methods. - """ - - pass - - -class IntegrationTestGrayquestApiLog(IntegrationTestCase): - """ - Integration tests for GrayQuestAPILog. - Use this class for testing interactions between multiple components. - """ - - pass diff --git a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py index 8f7d6d9..5830d4c 100644 --- a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py +++ b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py @@ -3,14 +3,15 @@ import base64 import frappe +import requests from frappe import _, db, response from frappe.auth import LoginManager +from frappe.integrations.utils import create_request_log from frappe.model.document import Document from frappe.utils import call_hook_method from payments.utils import create_payment_gateway from grayquest.utils import get_payload -from grayquest.utils.api_client import make_request from grayquest.utils.webhook import ( add_webhook_log, handle_emi_webhook, @@ -43,7 +44,6 @@ def get_payment_url(self, **kwargs): return url def generate_url(self, kwargs): - headers = self.get_headers() payload = get_payload(self, kwargs) api_url = self.api_url.strip("/") slug = self.slug @@ -51,18 +51,25 @@ def generate_url(self, kwargs): slug = self.event_slug endpoint = f"{api_url}/v1/pp/redirect/{slug}" - result = make_request( - url=endpoint, - data=payload, - headers=headers, - service="Initiate Payment", + reference_docname = kwargs.get("reference_docname") + response = self.make_request( method="POST", + endpoint=endpoint, + payload=payload, + reference_doctype=kwargs.get("reference_doctype"), + reference_docname=reference_docname, + request_description="GrayQuest Payment Request url", ) - if isinstance(result, dict) and result.get("data", {}).get("redirection_url"): - return result["data"]["redirection_url"] - - return result.get("message") if isinstance(result, dict) else None + if response.ok: + return response.json().get("data", {}).get("redirection_url") + else: + response_data = response.json() + frappe.log_error(_("GrayQuest Payment Gateway Error"), response_data) + frappe.throw( + _("GrayQuest Payment Error: {0}").format(response_data.get("message", "Unknown error")), + title=_("Payment Gateway Error"), + ) def get_headers(self): if self.api_key and self.client_id and self.client_secret: @@ -80,6 +87,70 @@ def get_headers(self): "Content-Type": "application/json", } + def log_request(self, service_name, data, url=None, **kwargs): + """Create an Integration Request log entry for GrayQuest API calls.""" + return create_request_log( + data=data, + service_name=service_name, + url=url, + **kwargs, + ) + + def make_request(self, method, endpoint, payload=None, params=None, + reference_doctype=None, reference_docname=None, + request_description=None): + """ + Make an HTTP request to GrayQuest API and log it via Integration Request. + + Args: + method: HTTP method ("GET" or "POST") + endpoint: Full API endpoint URL + payload: JSON body for POST requests + params: Query parameters for GET requests + reference_doctype: Linked document type for logging + reference_docname: Linked document name for logging + request_description: Description for the Integration Request log + + Returns: + requests.Response: Response from the API + """ + headers = self.get_headers() + + redacted_headers = { + key: "******" if key in ("Authorization", "GQ-API-Key") else value + for key, value in headers.items() + } + + integration_request = self.log_request( + service_name="GrayQuest", + data=payload or params or {}, + url=endpoint, + request_headers=redacted_headers, + reference_doctype=reference_doctype, + reference_docname=reference_docname, + request_description=request_description, + ) + + try: + if method == "POST": + response = requests.post(endpoint, headers=headers, json=payload) + elif method == "GET": + response = requests.get(endpoint, headers=headers, params=params) + else: + frappe.throw(_("Unsupported HTTP method: {0}").format(method)) + + response_data = response.json() + + if response.ok: + integration_request.handle_success(response_data) + else: + integration_request.handle_failure(response_data) + frappe.db.commit() + return response + except Exception: + integration_request.handle_failure({"error": frappe.get_traceback()}) + raise + def handle_webhook(self, data): # Add webhook log self.add_webhook_log(data) @@ -119,24 +190,26 @@ def check_payment_status(self, payment_request): """ api_url = self.api_url.strip("/") endpoint = f"{api_url}/v1/payments/fetch" - headers = self.get_headers() transaction_id = db.get_value("Payment Request", payment_request, "transaction_id") - payload = {"application_code": transaction_id} + params = {"application_code": transaction_id} - result = make_request( - url=endpoint, - data=payload, - headers=headers, - service="Check Payment Status", + res = self.make_request( method="GET", + endpoint=endpoint, + params=params, + reference_doctype="Payment Request", + reference_docname=payment_request, + request_description="GrayQuest Payment Status Check", ) - response["message"] = result + if not res.ok: + frappe.log_error(_("GrayQuest Payment Gateway Error"), res.json()) + + response["message"] = res.json() def _request_payment_url(self, payload, context="Payment"): """Make API request to GrayQuest and return payment URL.""" - headers = self.get_headers() - if not headers: + if not (self.api_key and self.client_id and self.client_secret): frappe.log_error( title="GrayQuest Configuration Error", message="API credentials not configured (api_key, client_id, or client_secret missing)" @@ -146,20 +219,20 @@ def _request_payment_url(self, payload, context="Payment"): api_url = self.api_url.strip("/") endpoint = f"{api_url}/v1/pp/redirect/{self.slug}" - result = make_request( - url=endpoint, - data=payload, - headers=headers, - service=f"Initiate Payment - {context}", + res = self.make_request( method="POST", + endpoint=endpoint, + payload=payload, + request_description=f"GrayQuest {context} Payment Request", ) - if isinstance(result, dict): - url = result.get("data", {}).get("redirection_url") + if res.ok: + url = res.json().get("data", {}).get("redirection_url") if url: return url - error_msg = result.get("message") or result.get("error") if isinstance(result, dict) else str(result) + response_data = res.json() + error_msg = response_data.get("message") or response_data.get("error") or f"Request failed: {res.status_code}" frappe.log_error( title=f"GrayQuest {context} API Error", message=f"Endpoint: {endpoint}\nResponse: {error_msg}" diff --git a/grayquest/utils/api_client.py b/grayquest/utils/api_client.py deleted file mode 100644 index d521d38..0000000 --- a/grayquest/utils/api_client.py +++ /dev/null @@ -1,82 +0,0 @@ -# Copyright (c) 2025, Hybrowlabs Technologies and contributors -# For license information, please see license.txt - -import json -import time - -import frappe -import requests - -from grayquest.grayquest.doctype.grayquest_api_log.grayquest_api_log import create_api_log - - -def make_request( - url: str, - data: dict | None = None, - headers: dict | None = None, - service: str = "API Request", - method: str = "POST", - timeout: int = 30, - is_json: bool = True, -) -> dict: - """Make HTTP request to GrayQuest API with logging.""" - request_id = frappe.generate_hash(length=10) - start_time = time.time() - - response = None - status_code = None - response_text = None - error = None - is_success = False - result = {} - - try: - if method.upper() == "POST": - if is_json: - response = requests.post(url, headers=headers, json=data, timeout=timeout) - else: - response = requests.post(url, headers=headers, data=data, timeout=timeout) - elif method.upper() == "GET": - response = requests.get(url, headers=headers, params=data, timeout=timeout) - else: - response = requests.request(method, url, headers=headers, json=data, timeout=timeout) - - status_code = response.status_code - response_text = response.text or None - - try: - result = response.json() - except (ValueError, json.JSONDecodeError): - result = {"raw_response": response.text} - - # GrayQuest uses status code 201 for successful payment URL creation - if status_code in (200, 201): - is_success = True - else: - error = result.get("message") or result.get("error") or f"Request failed: {status_code}" - - return result - - except Exception as e: - error = f"{e!s}\n\n{frappe.get_traceback()}" - frappe.log_error(title="GrayQuest API Error", message=f"URL: {url}\nService: {service}\n\n{error}") - return {"status": "error", "message": "Unable to connect to payment gateway."} - - finally: - execution_time_ms = int((time.time() - start_time) * 1000) - try: - create_api_log( - request_id=request_id, - service=service, - http_method=method, - full_url=url, - request_headers=headers, - request_payload=data, - status_code=status_code, - response_body=response_text, - execution_time_ms=execution_time_ms, - is_success=is_success, - error_details=error, - ) - except Exception: - pass diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index 8e5c4f8..00f2693 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -65,7 +65,7 @@ def _get_event_ticket_payload(ticket_doc, data): furl = data.get("failure_url") payload = { "student_id": guardian.name, - "customer_mobile": guardian.mobile_number or "9999999999", + "customer_mobile": _clean_mobile_number(guardian.mobile_number or "9999999999"), "customer_details": get_customer_details(guardian), "fee_headers": get_fee_headers(ticket_doc, data), "notes": get_notes(ticket_doc, data), @@ -105,13 +105,13 @@ def _get_student_payment_payload(controller, ref_doc, data): if student.applicant_name: customer_details["customer_first_name"] = student.applicant_name if student.email_id: - customer_details["customer_email"] = student.email_id + customer_details["customer_email"] = student.email_id.strip() if student.mobile: customer_mobile = student.mobile.replace("+91-", "").replace("+91", "") url = redirect_url or get_url() payload = { "student_id": student.name, - "customer_mobile": customer_mobile or student.student_mobile_number or "9999999999", + "customer_mobile": _clean_mobile_number(customer_mobile or student.student_mobile_number or "9999999999"), "fee_headers": get_fee_headers(ref_doc, data), "student_details": get_student_details(controller, student), "customer_details": customer_details, @@ -157,7 +157,7 @@ def get_student_details(controller, student): if student.gender: student_details["student_gender"] = student.gender.upper() if student.student_email_id: - student_details["student_email"] = student.student_email_id + student_details["student_email"] = student.student_email_id.strip() if joining_date: joining_date = get_date_str(joining_date) student_details["student_admission_date"] = joining_date @@ -192,7 +192,7 @@ def get_customer_details(guardian): if guardian.last_name: customer_details["customer_last_name"] = guardian.last_name if guardian.email_address: - customer_details["customer_email"] = guardian.email_address + customer_details["customer_email"] = guardian.email_address.strip() return customer_details @@ -524,7 +524,7 @@ def _get_student_applicant_details(controller, applicant): # Email if applicant.email_id: - student_details["student_email"] = applicant.email_id + student_details["student_email"] = applicant.email_id.strip() # Program/Class ID if controller.pass_class_id and applicant.program: @@ -579,7 +579,7 @@ def _get_student_applicant_customer_details(applicant): "" ) if customer_email: - customer_details["customer_email"] = customer_email + customer_details["customer_email"] = customer_email.strip() return customer_details @@ -714,7 +714,7 @@ def _get_student_notes(student): if student.gender: notes["student_gender"] = student.gender.upper() if student.student_email_id: - notes["student_email"] = student.student_email_id + notes["student_email"] = student.student_email_id.strip() if student.get("joining_date"): notes["student_admission_date"] = get_date_str(student.joining_date) return notes @@ -746,7 +746,7 @@ def get_applicant_payload_direct(controller, kwargs): if len(guardian_parts) > 1: customer_details["customer_last_name"] = " ".join(guardian_parts[1:]) if kwargs.get("payer_email") or applicant.student_email_id: - customer_details["customer_email"] = kwargs.get("payer_email") or applicant.student_email_id + customer_details["customer_email"] = (kwargs.get("payer_email") or applicant.student_email_id).strip() notes = { "description": f"Deposit payment for {student_name}", @@ -758,7 +758,7 @@ def get_applicant_payload_direct(controller, kwargs): if applicant.gender: notes["student_gender"] = applicant.gender.upper() if applicant.student_email_id: - notes["student_email"] = applicant.student_email_id + notes["student_email"] = applicant.student_email_id.strip() payload = { "student_id": applicant_id, From de4cdac0b181a1fa4bc3daf5f66e1ca6f1507079 Mon Sep 17 00:00:00 2001 From: badal8381 Date: Fri, 20 Feb 2026 05:25:41 +0000 Subject: [PATCH 02/16] refactor: improve API credential validation and sanitize name fields in webhook handlers --- .../grayquest_settings/grayquest_settings.py | 29 ++++---- grayquest/utils/utils.py | 70 +++++++++++++------ grayquest/utils/webhook.py | 2 +- 3 files changed, 65 insertions(+), 36 deletions(-) diff --git a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py index 5830d4c..9df6c5a 100644 --- a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py +++ b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py @@ -72,20 +72,25 @@ def generate_url(self, kwargs): ) def get_headers(self): - if self.api_key and self.client_id and self.client_secret: - client_secret = self.get_password("client_secret") - api_key = self.get_password("api_key") + if not (self.api_key and self.client_id and self.client_secret): + frappe.throw( + _("GrayQuest API credentials are not configured. Please set API Key, Client ID, and Client Secret in GrayQuest Settings."), + title=_("GrayQuest Configuration Error"), + ) - # Encode client_id and client_secret in base64 - credentials = f"{self.client_id}:{client_secret}" - auth_token = base64.b64encode(credentials.encode()).decode() + client_secret = self.get_password("client_secret") + api_key = self.get_password("api_key") - # Headers - return { - "Authorization": f"Basic {auth_token}", - "GQ-API-Key": api_key, - "Content-Type": "application/json", - } + # Encode client_id and client_secret in base64 + credentials = f"{self.client_id}:{client_secret}" + auth_token = base64.b64encode(credentials.encode()).decode() + + # Headers + return { + "Authorization": f"Basic {auth_token}", + "GQ-API-Key": api_key, + "Content-Type": "application/json", + } def log_request(self, service_name, data, url=None, **kwargs): """Create an Integration Request log entry for GrayQuest API calls.""" diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index 00f2693..8d7f5ac 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -4,6 +4,17 @@ from frappe.utils import flt, get_date_str, get_url +def _sanitize_alpha(value): + """Remove non-alphabetic characters from a string for GrayQuest API validation. + Returns None if the result is empty, so callers can skip the field entirely. + GrayQuest rejects empty values and non-alphabet characters in name fields. + """ + if not value: + return None + cleaned = re.sub(r'[^a-zA-Z]', '', str(value)).strip() + return cleaned or None + + def build_callback_url(payment_request): """ Build callback URL for GrayQuest redirect after payment. @@ -136,7 +147,7 @@ def get_student_details(controller, student): dict: A dictionary containing the student details. """ # Format date of birth and joining date - date_of_birth = get_date_str(student.date_of_birth) + date_of_birth = get_date_str(student.date_of_birth) if student.date_of_birth else None joining_date = student.get("joining_date") student_status = student.get("student_status") @@ -146,21 +157,25 @@ def get_student_details(controller, student): # Construct the student details dictionary student_details = {} - if student.first_name: - student_details["student_first_name"] = student.first_name - if student.middle_name: - student_details["student_middle_name"] = student.middle_name - if student.last_name: - student_details["student_last_name"] = student.last_name + first_name = _sanitize_alpha(student.first_name) + middle_name = _sanitize_alpha(student.middle_name) + last_name = _sanitize_alpha(student.last_name) + if first_name: + student_details["student_first_name"] = first_name + if middle_name: + student_details["student_middle_name"] = middle_name + if last_name: + student_details["student_last_name"] = last_name if date_of_birth: student_details["student_dob"] = date_of_birth - if student.gender: + if student.gender and student.gender.upper() in ("MALE", "FEMALE"): student_details["student_gender"] = student.gender.upper() if student.student_email_id: student_details["student_email"] = student.student_email_id.strip() if joining_date: - joining_date = get_date_str(joining_date) - student_details["student_admission_date"] = joining_date + formatted_joining_date = get_date_str(joining_date) + if formatted_joining_date: + student_details["student_admission_date"] = formatted_joining_date if student.blood_group: student_details["student_blood_group"] = student.blood_group student_details["student_type"] = "NEW" if not student_status or student_status == "New student" else "EXISTING" @@ -185,12 +200,15 @@ def get_customer_details(guardian): """ # Construct the customer details dictionary customer_details = {} - if guardian.first_name: - customer_details["customer_first_name"] = guardian.first_name - if guardian.middle_name: - customer_details["customer_middle_name"] = guardian.middle_name - if guardian.last_name: - customer_details["customer_last_name"] = guardian.last_name + first_name = _sanitize_alpha(guardian.first_name) + middle_name = _sanitize_alpha(guardian.middle_name) + last_name = _sanitize_alpha(guardian.last_name) + if first_name: + customer_details["customer_first_name"] = first_name + if middle_name: + customer_details["customer_middle_name"] = middle_name + if last_name: + customer_details["customer_last_name"] = last_name if guardian.email_address: customer_details["customer_email"] = guardian.email_address.strip() return customer_details @@ -507,9 +525,12 @@ def _get_student_applicant_details(controller, applicant): full_name = applicant.student_name or applicant.applicant_name or "" name_parts = full_name.split() if full_name else [] if name_parts: - student_details["student_first_name"] = name_parts[0] - if len(name_parts) > 1: - student_details["student_last_name"] = " ".join(name_parts[1:]) + first_name = _sanitize_alpha(name_parts[0]) + last_name = _sanitize_alpha(" ".join(name_parts[1:])) if len(name_parts) > 1 else None + if first_name: + student_details["student_first_name"] = first_name + if last_name: + student_details["student_last_name"] = last_name # Student Applicant is always NEW student_details["student_type"] = "NEW" @@ -519,7 +540,7 @@ def _get_student_applicant_details(controller, applicant): student_details["student_dob"] = get_date_str(applicant.date_of_birth) # Gender - if applicant.gender: + if applicant.gender and applicant.gender.upper() in ("MALE", "FEMALE"): student_details["student_gender"] = applicant.gender.upper() # Email @@ -567,9 +588,12 @@ def _get_student_applicant_customer_details(applicant): if guardian_name: name_parts = guardian_name.split() if guardian_name else [] if name_parts: - customer_details["customer_first_name"] = name_parts[0] - if len(name_parts) > 1: - customer_details["customer_last_name"] = " ".join(name_parts[1:]) + first_name = _sanitize_alpha(name_parts[0]) + last_name = _sanitize_alpha(" ".join(name_parts[1:])) if len(name_parts) > 1 else None + if first_name: + customer_details["customer_first_name"] = first_name + if last_name: + customer_details["customer_last_name"] = last_name # Try to get email customer_email = ( diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index c7633fe..091ef32 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -201,7 +201,7 @@ def handle_emi_webhook(data): except Exception as e: # Log the error and return an error message - frappe.log_error(f"EMI Webhook Error: {str(e)}", frappe.get_traceback()) + frappe.log_error("EMI Webhook Error", frappe.get_traceback()) response["message"] = _("Error in EMI Webhook") From 376458154706c2c9f89a5efdd38d7b3b792dc937 Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Fri, 20 Feb 2026 12:04:08 +0530 Subject: [PATCH 03/16] fix: set is_emi_payment on form submission and remove discount Mark is_emi_payment on emi.form.submitted, unmark on emi.rejected and emi.backout. Remove payment plan discount on form submission instead of at payment URL generation time. --- grayquest/utils/webhook.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index 091ef32..c614be6 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -164,12 +164,18 @@ def handle_emi_webhook(data): # Set EMI fields on Fees parent doc doc.emi_application_code = application_code - if event == "emi.process.completed" or event == "emi.disbursed": + if event in ("emi.form.submitted", "emi.process.completed", "emi.disbursed"): doc.is_emi_payment = 1 + elif event in ("emi.rejected", "emi.backout"): + doc.is_emi_payment = 0 # Update EMI status in the fees document update_emi_status(doc, event, timestamp) + # Remove payment plan discount once user has committed to EMI + if event == "emi.form.submitted": + doc.remove_payment_plan_discount() + # If the event is 'emi.disbursed', handle based on tranche type if event == "emi.disbursed": notes = data.get("notes", {}) or {} From aab97dd3b8fb57a977b7be7746af3dee849e9b74 Mon Sep 17 00:00:00 2001 From: badal8381 Date: Mon, 2 Mar 2026 06:39:29 +0000 Subject: [PATCH 04/16] fix: handle EMI payment redirect without processing payment - Skip handle_response for EMI payments on success page - Detect EMI when payment_term (udf_3) is missing or "EMI" - Show "EMI Form Submitted" message with redirect to fee page - Remove unnecessary Admin login from handle_response - Fees not marked as paid for EMI (webhooks handle disbursement) --- .../grayquest_settings/grayquest_settings.py | 39 +++---------------- grayquest/www/grayquest/success.html | 22 +++++++++++ grayquest/www/grayquest/success.py | 21 +++++++--- 3 files changed, 44 insertions(+), 38 deletions(-) diff --git a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py index 9df6c5a..9a3220d 100644 --- a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py +++ b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py @@ -271,51 +271,29 @@ def handle_response(self, data): Returns: dict: Result message """ - from frappe.auth import LoginManager - try: - login_manager = LoginManager() - login_manager.login_as("Administrator") - # Extract data udf_details = data.get("udf_details", {}) doctype = udf_details.get("udf_1") docname = udf_details.get("udf_2") - payment_term = udf_details.get("udf_3") - application_details = data.get("application_details", {}) - transaction_id = application_details.get("code") payment_details = data.get("payment_details", {}) status = payment_details.get("status") - amount = payment_details.get("amount") - - if status != "PAID": - return {"message": "Payment not completed"} if not frappe.db.exists(doctype, docname): frappe.log_error(f"GrayQuest: {doctype} {docname} does not exist") return {"message": "Document not found"} - doc = frappe.get_doc(doctype, docname, ignore_permissions=True) - if doctype == "Fees": - doc.on_payment_authorized( - status="Completed", - payment_term=payment_term, - transaction_id=transaction_id, - amount=amount - ) - return {"message": "Payment Successful"} + if status != "PAID": + return {"message": "Payment not completed"} + return {"message": "Payment successful for Fees"} elif doctype == "Student Applicant": - result = doc.on_payment_authorized( - status="Completed", - transaction_id=transaction_id, - amount=amount - ) - return result or {"message": "Applicant Payment Successful"} - + if status != "PAID": + return {"message": "Payment not completed"} + return {"message": "Payment successful for Student Applicant"} else: frappe.log_error(f"GrayQuest: Unsupported doctype {doctype}") return {"message": "Unsupported document type"} @@ -323,8 +301,3 @@ def handle_response(self, data): except Exception as e: frappe.log_error("GrayQuest handle_response Error", frappe.get_traceback()) return {"message": f"Payment processing failed: {str(e)}"} - finally: - try: - login_manager.logout() - except Exception: - pass diff --git a/grayquest/www/grayquest/success.html b/grayquest/www/grayquest/success.html index 23f49ee..9282af7 100644 --- a/grayquest/www/grayquest/success.html +++ b/grayquest/www/grayquest/success.html @@ -4,10 +4,15 @@

{{ title or "Processing Payment..." }}

{{ message or "Please wait while we process your payment." }}

+ {% if is_emi %} +

Redirecting to payment page in 5 seconds... +
Or click here to go now.

+ {% else %}

If you are not redirected automatically, click here.

+ {% endif %}
+ +{% if is_emi %} + +{% endif %} {% endblock %} diff --git a/grayquest/www/grayquest/success.py b/grayquest/www/grayquest/success.py index c1b92db..d4759f3 100644 --- a/grayquest/www/grayquest/success.py +++ b/grayquest/www/grayquest/success.py @@ -42,16 +42,14 @@ def get_context(context): "payment_details": payment_details, } - # Get controller and process response - controller = frappe.get_last_doc("GrayQuest Settings") - controller.handle_response(data) - - # Determine redirect URL to payment page + # Extract udf_details for routing udf_details = data.get("udf_details", {}) doctype = udf_details.get("udf_1") + payment_term = udf_details.get("udf_3") fee_hash = udf_details.get("udf_5") applicant_id = udf_details.get("udf_2") if doctype == "Student Applicant" else None + # Build redirect URL to the original payment/fee page if doctype == "Student Applicant" and applicant_id: redirect_url = f"/payment?applicant_id={applicant_id}" elif fee_hash: @@ -68,6 +66,19 @@ def get_context(context): else: redirect_url = "/" + # EMI: payment_term is "EMI" or not available in redirect data + # Don't process anything, just show EMI message and redirect to fee page + if doctype == "Fees" and (not payment_term or payment_term == "EMI"): + context.title = "EMI Form Submitted" + context.message = "You will get the receipt once the disbursal is successful." + context.redirect_url = redirect_url + context.is_emi = True + return + + # Non-EMI: process response via handle_response + controller = frappe.get_last_doc("GrayQuest Settings") + controller.handle_response(data) + frappe.local.response["type"] = "redirect" frappe.local.response["location"] = redirect_url From 4c7f9a37fef21fb2df24ac07f6d92cca7cbc3a29 Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Mon, 2 Mar 2026 18:15:17 +0530 Subject: [PATCH 05/16] refactor: update student details retrieval and EMI payment handling - Removed fetching of program name and sequence from student details. - Added retrieval of program abbreviation for student class ID assignment. - Implemented error logging for missing program abbreviation. - Enhanced EMI payment handling to use db_set for setting payment status. --- grayquest/utils/utils.py | 16 ++++++++-------- grayquest/utils/webhook.py | 6 ++++-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index 8d7f5ac..f4688d8 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -151,10 +151,6 @@ def get_student_details(controller, student): joining_date = student.get("joining_date") student_status = student.get("student_status") - # Fetch program name - program_name = frappe.get_value("Program", student.program, "program_name") - sequence = frappe.get_value("Program", student.program, "sequence") - # Construct the student details dictionary student_details = {} first_name = _sanitize_alpha(student.first_name) @@ -180,11 +176,15 @@ def get_student_details(controller, student): student_details["student_blood_group"] = student.blood_group student_details["student_type"] = "NEW" if not student_status or student_status == "New student" else "EXISTING" + print(f"Pass Class ID: {controller.pass_class_id}") + frappe.log_error("Pass Class ID", f"Pass Class ID: {controller.pass_class_id}") if controller.pass_class_id: - if program_name.isdigit(): - student_details["student_class_id"] = int(program_name) - elif sequence: - student_details["student_class_id"] = int(sequence) + program_abbr = frappe.get_value("Program", student.program, "program_abbreviation") + if program_abbr: + student_details["student_class_id"] = int(program_abbr) + else: + frappe.log_error(title="Program Abbreviation", message="Program Abbreviation not found") + return student_details diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index c614be6..2cc2f11 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -165,9 +165,11 @@ def handle_emi_webhook(data): # Set EMI fields on Fees parent doc doc.emi_application_code = application_code if event in ("emi.form.submitted", "emi.process.completed", "emi.disbursed"): - doc.is_emi_payment = 1 + if hasattr(doc, "is_emi_payment"): + doc.db_set("is_emi_payment", 1) elif event in ("emi.rejected", "emi.backout"): - doc.is_emi_payment = 0 + if hasattr(doc, "is_emi_payment"): + doc.db_set("is_emi_payment", 0) # Update EMI status in the fees document update_emi_status(doc, event, timestamp) From 83e70e41dde656cff40fa212c3a5b5352c21e978 Mon Sep 17 00:00:00 2001 From: badal8381 Date: Mon, 2 Mar 2026 13:42:10 +0000 Subject: [PATCH 06/16] refactor: enhance student details retrieval with class ID in payload --- grayquest/utils/utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index f4688d8..7624b35 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -176,8 +176,6 @@ def get_student_details(controller, student): student_details["student_blood_group"] = student.blood_group student_details["student_type"] = "NEW" if not student_status or student_status == "New student" else "EXISTING" - print(f"Pass Class ID: {controller.pass_class_id}") - frappe.log_error("Pass Class ID", f"Pass Class ID: {controller.pass_class_id}") if controller.pass_class_id: program_abbr = frappe.get_value("Program", student.program, "program_abbreviation") if program_abbr: @@ -656,7 +654,7 @@ def get_fees_payload(controller, kwargs): "student_id": student_id, "customer_mobile": _clean_mobile_number(student.student_mobile_number or "9999999999"), "fee_headers": fee_headers, - "student_details": _get_student_details_minimal(student), + "student_details": _get_student_details_minimal(student, controller), "customer_details": customer_details, "notes": notes, "udf_details": { @@ -719,14 +717,18 @@ def _apply_payment_prefixes(base_headers, controller): return result -def _get_student_details_minimal(student): - """Get minimal student details (first_name, last_name, student_type) for GrayQuest payload.""" +def _get_student_details_minimal(student, controller=None): + """Get minimal student details (first_name, last_name, student_type, class_id) for GrayQuest payload.""" student_status = student.get("student_status") details = {"student_type": "NEW" if not student_status or student_status == "New student" else "EXISTING"} if student.first_name: details["student_first_name"] = student.first_name if student.last_name: details["student_last_name"] = student.last_name + if controller and getattr(controller, "pass_class_id", False) and student.program: + program_abbr = frappe.get_value("Program", student.program, "program_abbreviation") + if program_abbr: + details["student_class_id"] = int(program_abbr) return details From 692f4a4e2aea1a0e9645f20c8879bc01ad2fb3c1 Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Wed, 4 Mar 2026 11:38:33 +0530 Subject: [PATCH 07/16] refactor: streamline student details retrieval and class ID assignment - Consolidated class ID retrieval into a dedicated function for better readability and maintainability. - Updated student details functions to utilize the new class ID retrieval method. - Removed redundant code related to program name and sequence fetching. --- grayquest/utils/utils.py | 48 ++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index 7624b35..e7da6db 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -176,12 +176,9 @@ def get_student_details(controller, student): student_details["student_blood_group"] = student.blood_group student_details["student_type"] = "NEW" if not student_status or student_status == "New student" else "EXISTING" - if controller.pass_class_id: - program_abbr = frappe.get_value("Program", student.program, "program_abbreviation") - if program_abbr: - student_details["student_class_id"] = int(program_abbr) - else: - frappe.log_error(title="Program Abbreviation", message="Program Abbreviation not found") + class_id = _get_class_id(controller, student.program) + if class_id is not None: + student_details["student_class_id"] = class_id return student_details @@ -545,14 +542,9 @@ def _get_student_applicant_details(controller, applicant): if applicant.email_id: student_details["student_email"] = applicant.email_id.strip() - # Program/Class ID - if controller.pass_class_id and applicant.program: - program_name = frappe.get_value("Program", applicant.program, "program_name") or applicant.program - sequence = frappe.get_value("Program", applicant.program, "sequence") - if program_name and str(program_name).isdigit(): - student_details["student_class_id"] = int(program_name) - elif sequence: - student_details["student_class_id"] = int(sequence) + class_id = _get_class_id(controller, applicant.program) + if class_id is not None: + student_details["student_class_id"] = class_id return student_details @@ -606,6 +598,15 @@ def _get_student_applicant_customer_details(applicant): return customer_details +def _get_class_id(controller, program): + """Get class_id from program_abbreviation if pass_class_id is enabled. """ + if controller.pass_class_id and program: + abbr = frappe.get_value("Program", program, "program_abbreviation") + if abbr: + return int(abbr) + return None + + def _clean_mobile_number(mobile): """ Clean mobile number to 10 digits for GrayQuest API. @@ -654,7 +655,7 @@ def get_fees_payload(controller, kwargs): "student_id": student_id, "customer_mobile": _clean_mobile_number(student.student_mobile_number or "9999999999"), "fee_headers": fee_headers, - "student_details": _get_student_details_minimal(student, controller), + "student_details": _get_student_details_minimal(controller, student), "customer_details": customer_details, "notes": notes, "udf_details": { @@ -717,18 +718,19 @@ def _apply_payment_prefixes(base_headers, controller): return result -def _get_student_details_minimal(student, controller=None): - """Get minimal student details (first_name, last_name, student_type, class_id) for GrayQuest payload.""" +def _get_student_details_minimal(controller, student): + """Get minimal student details (first_name, last_name, student_type) for GrayQuest payload.""" student_status = student.get("student_status") details = {"student_type": "NEW" if not student_status or student_status == "New student" else "EXISTING"} if student.first_name: details["student_first_name"] = student.first_name if student.last_name: details["student_last_name"] = student.last_name - if controller and getattr(controller, "pass_class_id", False) and student.program: - program_abbr = frappe.get_value("Program", student.program, "program_abbreviation") - if program_abbr: - details["student_class_id"] = int(program_abbr) + + class_id = _get_class_id(controller, student.program) + if class_id is not None: + details["student_class_id"] = class_id + return details @@ -763,6 +765,10 @@ def get_applicant_payload_direct(controller, kwargs): if len(name_parts) > 1: student_details["student_last_name"] = " ".join(name_parts[1:]) + class_id = _get_class_id(controller, applicant.program) + if class_id is not None: + student_details["student_class_id"] = class_id + customer_details = {} guardian_name = getattr(applicant, 'guardian_name', None) or getattr(applicant, 'father_name', None) or student_name if guardian_name: From 88c531a8a11cd9d88e07708e63324f592afa4752 Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Wed, 4 Mar 2026 17:19:20 +0530 Subject: [PATCH 08/16] fix: use fee program instead of student program for class_id in GrayQuest payload When a student is promoted but has outstanding fees from the previous class, the class_id was incorrectly derived from the student's current program. Now fetches the program from the Fees document for accurate class_id mapping. --- grayquest/utils/utils.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index e7da6db..1341435 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -655,7 +655,7 @@ def get_fees_payload(controller, kwargs): "student_id": student_id, "customer_mobile": _clean_mobile_number(student.student_mobile_number or "9999999999"), "fee_headers": fee_headers, - "student_details": _get_student_details_minimal(controller, student), + "student_details": _get_student_details_minimal(controller, student, kwargs), "customer_details": customer_details, "notes": notes, "udf_details": { @@ -718,7 +718,7 @@ def _apply_payment_prefixes(base_headers, controller): return result -def _get_student_details_minimal(controller, student): +def _get_student_details_minimal(controller, student, kwargs=None): """Get minimal student details (first_name, last_name, student_type) for GrayQuest payload.""" student_status = student.get("student_status") details = {"student_type": "NEW" if not student_status or student_status == "New student" else "EXISTING"} @@ -727,7 +727,10 @@ def _get_student_details_minimal(controller, student): if student.last_name: details["student_last_name"] = student.last_name - class_id = _get_class_id(controller, student.program) + program = student.program + if kwargs and kwargs.get("reference_doctype") == "Fees" and kwargs.get("reference_docname"): + program = frappe.db.get_value("Fees", kwargs["reference_docname"], "program") or program + class_id = _get_class_id(controller, program) if class_id is not None: details["student_class_id"] = class_id From 79c40577faec0907abea77f0563a53da9c5f7c3d Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Mon, 9 Mar 2026 13:37:20 +0530 Subject: [PATCH 09/16] fix: remove payment processing from success page and guard webhook log against None doctype - Success page now only redirects, payment processing is handled by webhook - Add None check for doctype in add_webhook_log to prevent TypeError --- grayquest/utils/webhook.py | 2 +- grayquest/www/grayquest/success.py | 5 +---- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index 2cc2f11..72ca4ec 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -283,7 +283,7 @@ def add_webhook_log(data): student = db.get_value(doctype, docname, "party") elif doctype == "Fees": student = db.get_value(doctype, docname, "student") - elif frappe.db.has_column(doctype, "student"): + elif doctype and frappe.db.has_column(doctype, "student"): student = db.get_value(doctype, docname, "student") else: student = None diff --git a/grayquest/www/grayquest/success.py b/grayquest/www/grayquest/success.py index d4759f3..42f2d04 100644 --- a/grayquest/www/grayquest/success.py +++ b/grayquest/www/grayquest/success.py @@ -75,10 +75,7 @@ def get_context(context): context.is_emi = True return - # Non-EMI: process response via handle_response - controller = frappe.get_last_doc("GrayQuest Settings") - controller.handle_response(data) - + # Just redirect — payment processing is handled by the webhook frappe.local.response["type"] = "redirect" frappe.local.response["location"] = redirect_url From eb3b5deab734bee2ab8def84d4cdad88019dbee6 Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Thu, 12 Mar 2026 11:08:58 +0530 Subject: [PATCH 10/16] fix: pass empty mobile number instead of hardcoded 9999999999 fallback When no valid mobile number is available, send empty string to GrayQuest instead of the hardcoded 9999999999 placeholder. --- grayquest/utils/utils.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index 1341435..41855c1 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -76,7 +76,7 @@ def _get_event_ticket_payload(ticket_doc, data): furl = data.get("failure_url") payload = { "student_id": guardian.name, - "customer_mobile": _clean_mobile_number(guardian.mobile_number or "9999999999"), + "customer_mobile": _clean_mobile_number(guardian.mobile_number or ""), "customer_details": get_customer_details(guardian), "fee_headers": get_fee_headers(ticket_doc, data), "notes": get_notes(ticket_doc, data), @@ -122,7 +122,7 @@ def _get_student_payment_payload(controller, ref_doc, data): url = redirect_url or get_url() payload = { "student_id": student.name, - "customer_mobile": _clean_mobile_number(customer_mobile or student.student_mobile_number or "9999999999"), + "customer_mobile": _clean_mobile_number(customer_mobile or student.student_mobile_number or ""), "fee_headers": get_fee_headers(ref_doc, data), "student_details": get_student_details(controller, student), "customer_details": customer_details, @@ -473,7 +473,7 @@ def _get_student_applicant_payload(controller, ref_doc, data): applicant = frappe.get_doc("Student Applicant", ref_doc.reference_name) # Get and clean mobile number - raw_mobile = applicant.student_mobile_number or applicant.mobile or "9999999999" + raw_mobile = applicant.student_mobile_number or applicant.mobile or "" customer_mobile = _clean_mobile_number(raw_mobile) # Build callback URL (simple - just payment_request) @@ -622,8 +622,8 @@ def _clean_mobile_number(mobile): if len(digits) > 10: digits = digits[-10:] if len(digits) < 10: - # Invalid mobile number - use default to not block payment flow - digits = "9999999999" + # Invalid mobile number - pass empty to let GrayQuest handle it + digits = "" return digits @@ -653,7 +653,7 @@ def get_fees_payload(controller, kwargs): payload = { "student_id": student_id, - "customer_mobile": _clean_mobile_number(student.student_mobile_number or "9999999999"), + "customer_mobile": _clean_mobile_number(student.student_mobile_number or ""), "fee_headers": fee_headers, "student_details": _get_student_details_minimal(controller, student, kwargs), "customer_details": customer_details, @@ -797,7 +797,7 @@ def get_applicant_payload_direct(controller, kwargs): payload = { "student_id": applicant_id, - "customer_mobile": _clean_mobile_number(kwargs.get("payer_phone") or applicant.student_mobile_number or "9999999999"), + "customer_mobile": _clean_mobile_number(kwargs.get("payer_phone") or applicant.student_mobile_number or ""), "fee_headers": fee_headers, "student_details": student_details, "customer_details": customer_details, From b4845157a720eaac188bcf6fa6bba528dc667e6c Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Fri, 27 Mar 2026 16:47:27 +0530 Subject: [PATCH 11/16] refactor: support separate EMI and PG split amounts in fee headers - Accept split_payments_emi in _build_fee_headers for separate EMI splits - _apply_payment_prefixes uses pg_headers for _PG and emi_headers for _EMI - Add isinstance guard for split_payments_emi type safety - Backward compatible: falls back to pg_headers when emi splits not provided --- grayquest/utils/utils.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index 41855c1..758ea83 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -641,8 +641,9 @@ def get_fees_payload(controller, kwargs): fee_hash = kwargs.get("fee_hash", "") split_payments = kwargs.get("split_payments", {}) + split_payments_emi = kwargs.get("split_payments_emi") amount = flt(kwargs.get("amount", 0), 2) - fee_headers = _build_fee_headers(split_payments, controller, amount) + fee_headers = _build_fee_headers(split_payments, controller, amount, split_payments_emi=split_payments_emi) notes = { "description": f"Fee payment for {student.student_name}", @@ -674,7 +675,7 @@ def get_fees_payload(controller, kwargs): return payload -def _build_fee_headers(split_payments, controller=None, amount=None): +def _build_fee_headers(split_payments, controller=None, amount=None, split_payments_emi=None): """Build fee_headers with EMI/PG prefixes when split payment enabled, else use default_label.""" if not split_payments or not isinstance(split_payments, dict): if controller and controller.default_label and amount: @@ -689,12 +690,13 @@ def _build_fee_headers(split_payments, controller=None, amount=None): ) frappe.throw("Unable to process payment. Please contact support.") - base_headers = {label: flt(amt, 2) for label, amt in split_payments.items()} - return _apply_payment_prefixes(base_headers, controller) + pg_headers = {label: flt(amt, 2) for label, amt in split_payments.items()} + emi_headers = {label: flt(amt, 2) for label, amt in split_payments_emi.items()} if split_payments_emi else pg_headers + return _apply_payment_prefixes(pg_headers, controller, emi_headers=emi_headers) -def _apply_payment_prefixes(base_headers, controller): - """Apply _EMI/_PG suffixes to fee headers. Throws error if neither EMI nor PG is enabled.""" +def _apply_payment_prefixes(pg_headers, controller, emi_headers=None): + """Apply _EMI/_PG suffixes to fee headers using separate amounts for each mode.""" if not controller: frappe.log_error(title="GrayQuest Configuration Error", message="GrayQuest Settings not configured.") frappe.throw("Unable to process payment. Please contact support.") @@ -711,9 +713,10 @@ def _apply_payment_prefixes(base_headers, controller): result = {} if pg_enabled: - result.update({f"{label}_PG": amt for label, amt in base_headers.items()}) + result.update({f"{label}_PG": amt for label, amt in pg_headers.items()}) if emi_enabled: - result.update({f"{label}_EMI": amt for label, amt in base_headers.items()}) + headers = emi_headers or pg_headers + result.update({f"{label}_EMI": amt for label, amt in headers.items()}) return result From d7f1fa5dc9c4bfc92638deb3bb88bc28e2c740e0 Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Thu, 23 Apr 2026 16:57:33 +0530 Subject: [PATCH 12/16] fix: extend EMI event handling to include additional states - Updated the webhook to remove payment plan discount for "emi.process.completed" and "emi.disbursed" events, in addition to "emi.form.submitted". This ensures discounts are removed appropriately as the EMI process progresses. --- grayquest/utils/webhook.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index 72ca4ec..93a954c 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -175,7 +175,7 @@ def handle_emi_webhook(data): update_emi_status(doc, event, timestamp) # Remove payment plan discount once user has committed to EMI - if event == "emi.form.submitted": + if event in ("emi.form.submitted", "emi.process.completed", "emi.disbursed"): doc.remove_payment_plan_discount() # If the event is 'emi.disbursed', handle based on tranche type From f60dbd9b621ea6d63a9e1d7eb4687eb5f85aa8df Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Thu, 30 Jul 2026 11:41:40 +0530 Subject: [PATCH 13/16] feat: JBCN Stage-1 application fee payment support Add a dedicated application-fee path alongside the existing deposit/one-time flow: - get_application_fee_payment_url on GrayQuest Settings: generates a hosted payment URL for a direct Student Applicant application fee (no Payment Request). - get_application_fee_payload + _application_fee_headers in utils: builds the payload with a plain "Application Fee" breakup label, keyed on reference_doctype/reference_docname, and stamps udf_3="application_fee" (discriminator) and udf_4=amount. - webhook: on a PAID callback with udf_3="application_fee", settle via applicant.settle_application_fee_payment (distinct from validate_one_time_payment); on failure, log and hold status so the parent can retry on the same link. Per-account routing (default_label / split rules) and webhook signature verification are intentionally deferred. --- .../grayquest_settings/grayquest_settings.py | 9 +++ grayquest/utils/__init__.py | 1 + grayquest/utils/utils.py | 67 +++++++++++++++++++ grayquest/utils/webhook.py | 14 ++++ 4 files changed, 91 insertions(+) diff --git a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py index 9a3220d..d7ff6bf 100644 --- a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py +++ b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py @@ -256,6 +256,15 @@ def get_payment_url_applicant(self, **kwargs): payload = get_applicant_payload_direct(self, kwargs) return self._request_payment_url(payload, context="Applicant") + def get_application_fee_payment_url(self, **kwargs): + """Generate a hosted payment URL for a Stage-1 Application Fee (direct, + no Payment Request). kwargs: reference_doctype, reference_docname, amount, + description, success_url, failure_url. The callback carries + udf_3="application_fee" for routing.""" + from grayquest.utils import get_application_fee_payload + payload = get_application_fee_payload(self, kwargs) + return self._request_payment_url(payload, context="Application Fee") + def handle_response(self, data): """ Handle payment response from GrayQuest success page (like Easebuzz handle_response). diff --git a/grayquest/utils/__init__.py b/grayquest/utils/__init__.py index 4d30bd9..43f9564 100644 --- a/grayquest/utils/__init__.py +++ b/grayquest/utils/__init__.py @@ -4,6 +4,7 @@ get_customer_details, get_fees_payload, get_applicant_payload_direct, + get_application_fee_payload, ) EMI_STATUS_MAPPING = { diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index 758ea83..b2047e1 100644 --- a/grayquest/utils/utils.py +++ b/grayquest/utils/utils.py @@ -817,3 +817,70 @@ def get_applicant_payload_direct(controller, kwargs): } return payload + + +def get_application_fee_payload(controller, kwargs): + """Build a GrayQuest payload for a Stage-1 Application Fee payment (direct, no + Payment Request). Discriminated by udf_3="application_fee" so the webhook routes + to the JBCN application-fee settlement, NOT the deposit one-time flow. + + kwargs: reference_doctype, reference_docname (the Student Applicant), amount, + description, success_url, failure_url. + """ + reference_doctype = kwargs.get("reference_doctype") or "Student Applicant" + applicant_id = kwargs.get("reference_docname") or kwargs.get("applicant") or kwargs.get("applicant_id") + applicant = frappe.get_doc("Student Applicant", applicant_id) + student_name = f"{applicant.first_name or ''} {applicant.last_name or ''}".strip() + amount = flt(kwargs.get("amount", 0), 2) + fee_headers = _application_fee_headers(controller, amount) + + student_details = {"student_type": "NEW"} + name_parts = student_name.split() + if name_parts: + student_details["student_first_name"] = name_parts[0] + if len(name_parts) > 1: + student_details["student_last_name"] = " ".join(name_parts[1:]) + + customer_details = {} + guardian_name = getattr(applicant, "guardian_name", None) or getattr(applicant, "father_name", None) or student_name + if guardian_name: + guardian_parts = guardian_name.split() + if guardian_parts: + customer_details["customer_first_name"] = guardian_parts[0] + if len(guardian_parts) > 1: + customer_details["customer_last_name"] = " ".join(guardian_parts[1:]) + email = kwargs.get("payer_email") or getattr(applicant, "student_email_id", None) + if email: + customer_details["customer_email"] = email.strip() + + return { + "student_id": applicant_id, + "customer_mobile": _clean_mobile_number( + kwargs.get("payer_phone") or getattr(applicant, "student_mobile_number", "") or "" + ), + "fee_headers": fee_headers, + "student_details": student_details, + "customer_details": customer_details, + "notes": { + "description": kwargs.get("description") or f"Application Fee for {student_name}", + "reference_doctype": "Student Applicant", + "reference_docname": applicant_id, + }, + "udf_details": { + "udf_1": reference_doctype, + "udf_2": applicant_id, + "udf_3": "application_fee", + "udf_4": amount, + }, + "redirection": { + "success_url": kwargs.get("success_url") or f"{get_url()}/grayquest/success", + "error_url": kwargs.get("failure_url") or f"{get_url()}/grayquest/failure", + }, + } + + +def _application_fee_headers(controller, amount): + """Single-line fee breakup for the application fee. A plain parent-facing label; + routing here is by the GrayQuest merchant slug. Per-account routing (Bank Account + via default_label / a Gateway Split Rule) is deferred to BRD 05 Stage 2.""" + return {"Application Fee": flt(amount, 2)} diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index 93a954c..031b3c5 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -60,6 +60,14 @@ def handle_payment_gateway_webhook(data): # Handle direct Student Applicant payment if doctype == "Student Applicant" and payment_details.get("status") == "PAID": + # Stage-1 application fee (BRD 05): discriminated by udf_3 and settled via the + # JBCN idempotent resolver on the doc — distinct from the deposit one-time flow. + if payment_term == "application_fee": + doc.settle_application_fee_payment( + "Paid", amount=amount, transaction_reference=application_code + ) + response["message"] = _("Application Fee Payment Captured") + return result = doc.on_payment_authorized( status="Completed", transaction_id=application_code, @@ -115,6 +123,12 @@ def handle_payment_gateway_webhook(data): # Get doctype and docname from udf_details doctype = udf_details.get("udf_1") docname = udf_details.get("udf_2") + payment_term = udf_details.get("udf_3") + # Application fee failure (BRD 05 §D.1): the webhook log is the record; leave the + # applicant status untouched so the parent can retry on the same link. + if doctype == "Student Applicant" and payment_term == "application_fee": + response["message"] = _("Application fee payment failed. Please retry on the same link.") + return doc = get_doc(doctype, docname) if hasattr(doc, "validate_failed_payment"): res = doc.validate_failed_payment(data) From 50b2dcec7744fc728218bc70dccad614a0f2f11e Mon Sep 17 00:00:00 2001 From: Sonali Date: Thu, 6 Aug 2026 12:18:13 +0000 Subject: [PATCH 14/16] fix: track EMI state per installment on Payment Schedule Partially reverts the parent-doc move made in 1fad43b. Marking is_emi_payment on the Fees parent leaves no way to express "this installment is under EMI", so funding installment 1 blocked payment of installment 2 permanently. Note that emi.process.completed means the process finished, so latching the flag there is what made the block permanent. - read the originating installment from udf_3 and record it on GrayQuest EMI Status - set/clear the blocking flag on the matching Payment Schedule row; only in-flight events block, every terminal event releases - clear the parent flag on emi.rejected / emi.backout - pass payment_term into handle_emi_payment so duplicate emi.disbursed webhooks resolve to the same installment instead of the next unpaid one The Payment Schedule custom field is owned by edu_quality's module customisations, not this app's fixtures, so the hooks.py fixtures filter stays as it is. --- .../grayquest_emi_status.json | 10 ++- grayquest/utils/__init__.py | 15 ++++ grayquest/utils/webhook.py | 78 +++++++++++++++---- 3 files changed, 87 insertions(+), 16 deletions(-) diff --git a/grayquest/grayquest/doctype/grayquest_emi_status/grayquest_emi_status.json b/grayquest/grayquest/doctype/grayquest_emi_status/grayquest_emi_status.json index 120a3ce..88b33f8 100644 --- a/grayquest/grayquest/doctype/grayquest_emi_status/grayquest_emi_status.json +++ b/grayquest/grayquest/doctype/grayquest_emi_status/grayquest_emi_status.json @@ -7,6 +7,7 @@ "engine": "InnoDB", "field_order": [ "status", + "payment_term", "column_break_aeep", "timestamp" ], @@ -18,6 +19,13 @@ "label": "Status", "options": "Form Submitted\nApproved\nDownpayment Captured\nProcess Completed\nDisbursed\nInstallment Captured\nInstallment Overdue\nClosed\nBackout\nRejected" }, + { + "description": "Installment this EMI application was raised for (GrayQuest udf_3).", + "fieldname": "payment_term", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Payment Term" + }, { "fieldname": "column_break_aeep", "fieldtype": "Column Break" @@ -32,7 +40,7 @@ "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2026-01-30 14:30:00.000000", + "modified": "2026-08-06 10:00:00.000000", "modified_by": "Administrator", "module": "GrayQuest", "name": "GrayQuest EMI Status", diff --git a/grayquest/utils/__init__.py b/grayquest/utils/__init__.py index 4d30bd9..3bec437 100644 --- a/grayquest/utils/__init__.py +++ b/grayquest/utils/__init__.py @@ -17,4 +17,19 @@ "emi.closed": "Closed", "emi.backout": "Backout", "emi.rejected": "Rejected", +} + +# An EMI application only blocks payment while it is genuinely being processed. +# Everything outside this set is terminal - note that "emi.process.completed" +# means the process finished, so it must not block. +EMI_IN_FLIGHT_EVENTS = { + "emi.form.submitted", + "emi.approved", + "emi.downpayment.captured", +} + +# Events after which the application can no longer fund anything. +EMI_UNSUCCESSFUL_EVENTS = { + "emi.rejected", + "emi.backout", } \ No newline at end of file diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index 93a954c..58cca6a 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -2,7 +2,11 @@ from frappe import _, db, get_doc, response from frappe.utils import get_datetime, now_datetime -from grayquest.utils import EMI_STATUS_MAPPING +from grayquest.utils import ( + EMI_IN_FLIGHT_EVENTS, + EMI_STATUS_MAPPING, + EMI_UNSUCCESSFUL_EVENTS, +) def handle_payment_gateway_webhook(data): @@ -141,14 +145,17 @@ def handle_emi_webhook(data): Details: - udf_details contains doctype (Payment Request or Fees) and docname + - udf_3 carries the installment the application was raised for - EMI fields (is_emi_payment, emi_application_code) stored on Fees parent doc - - is_emi_payment marked on "emi.process.completed" or "emi.disbursed" event + - the blocking flag lives on the matching Payment Schedule row, so funding + one installment never blocks payment of the next one """ try: # Extract user-defined fields (udf) details from the webhook data udf_details = data.get("udf_details", {}) doctype = udf_details.get("udf_1") docname = udf_details.get("udf_2") + payment_term = udf_details.get("udf_3") # Extract application details from the webhook data application_details = data.get("application_details") @@ -162,17 +169,20 @@ def handle_emi_webhook(data): if doctype == "Fees": doc = get_doc(doctype, docname) - # Set EMI fields on Fees parent doc + # Set EMI fields on Fees parent doc. The parent flag records that this + # fee is EMI funded (it drives the EMI Details tab and discount gating); + # it is deliberately not what the payment portal blocks on. doc.emi_application_code = application_code - if event in ("emi.form.submitted", "emi.process.completed", "emi.disbursed"): - if hasattr(doc, "is_emi_payment"): - doc.db_set("is_emi_payment", 1) - elif event in ("emi.rejected", "emi.backout"): - if hasattr(doc, "is_emi_payment"): - doc.db_set("is_emi_payment", 0) + if event == "emi.process.completed" or event == "emi.disbursed": + doc.is_emi_payment = 1 + elif event in EMI_UNSUCCESSFUL_EVENTS: + doc.is_emi_payment = 0 + + # Mark/clear the blocking flag on the installment this application belongs to + set_term_emi_flag(doc, payment_term, event) # Update EMI status in the fees document - update_emi_status(doc, event, timestamp) + update_emi_status(doc, event, timestamp, payment_term) # Remove payment plan discount once user has committed to EMI if event in ("emi.form.submitted", "emi.process.completed", "emi.disbursed"): @@ -182,7 +192,9 @@ def handle_emi_webhook(data): if event == "emi.disbursed": notes = data.get("notes", {}) or {} is_second_disbursal = bool(notes.get("id")) - message = doc.handle_emi_payment(application_code, is_second_disbursal) + message = doc.handle_emi_payment( + application_code, is_second_disbursal, payment_term=payment_term + ) response["message"] = message else: response["message"] = _("EMI Status Updated") @@ -198,7 +210,7 @@ def handle_emi_webhook(data): doc = get_doc(doctype, docname) # Update EMI status in the payment request - update_emi_status(doc, event, timestamp) + update_emi_status(doc, event, timestamp, payment_term) # If the event is 'emi.disbursed', mark the payment as authorized/completed if event == "emi.disbursed": @@ -229,7 +241,38 @@ def handle_response_web_form(data): frappe.log_error(f"{doctype} {docname} does not exist") -def update_emi_status(doc, event, timestamp): +def set_term_emi_flag(doc, payment_term, event): + """ + Mark or clear the EMI blocking flag on a single installment. + + Args: + doc (Document): Fees document + payment_term (str): Installment the EMI application was raised for (udf_3) + event (str): Webhook event + + Details: + - the flag is set only while the application is in flight, and cleared on + every terminal event (disbursed, process completed, rejected, backout, closed) + - written with db.set_value so it persists regardless of the parent's save path + - a payload without udf_3 is left alone rather than guessed at + """ + if not payment_term: + return + + in_flight = 1 if event in EMI_IN_FLIGHT_EVENTS else 0 + for schedule in doc.payment_schedule: + if str(schedule.payment_term) == str(payment_term): + db.set_value( + "Payment Schedule", schedule.name, "is_emi_payment", in_flight, update_modified=False + ) + schedule.is_emi_payment = in_flight + # set_value only busts the child row's cache, but the payment portal reads + # the parent through get_cached_doc - without this it serves stale rows. + frappe.clear_document_cache("Fees", doc.name) + return + + +def update_emi_status(doc, event, timestamp, payment_term=None): """ Update EMI Status in Payment Request or Fees @@ -237,6 +280,7 @@ def update_emi_status(doc, event, timestamp): doc (Document): Payment Request or Fees document event (str): Webhook event timestamp (str): Webhook timestamp + payment_term (str): Installment the EMI application was raised for (udf_3) """ if not timestamp: timestamp = now_datetime() @@ -250,10 +294,14 @@ def update_emi_status(doc, event, timestamp): if not status: return - doc.append("emi_status", { + row = { "status": status, "timestamp": timestamp, - }) + } + if payment_term: + row["payment_term"] = payment_term + + doc.append("emi_status", row) doc.save(ignore_permissions=True) doc.reload() From 531ea610085a76c6f350cbdb17113c3055d26d2c Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Tue, 18 Aug 2026 13:15:13 +0530 Subject: [PATCH 15/16] fix: keep the EMI marker on an installment EMI has settled set_term_emi_flag cleared the flag on every terminal event, including the Process Completed rows that trail a disbursal - five of them on the fee that prompted this. That erased the record of which installment EMI funded, written when the payment is authorised. Clearing only ever serves to release an installment someone still has to pay, so it is now skipped once the installment has no outstanding left. A rejection or backout on an unpaid installment still releases the payment portal exactly as before. --- grayquest/utils/webhook.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index 58cca6a..ce42e19 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -1,6 +1,6 @@ import frappe from frappe import _, db, get_doc, response -from frappe.utils import get_datetime, now_datetime +from frappe.utils import flt, get_datetime, now_datetime from grayquest.utils import ( EMI_IN_FLIGHT_EVENTS, @@ -251,8 +251,12 @@ def set_term_emi_flag(doc, payment_term, event): event (str): Webhook event Details: - - the flag is set only while the application is in flight, and cleared on - every terminal event (disbursed, process completed, rejected, backout, closed) + - the flag is set while the application is in flight, and cleared on every + terminal event (disbursed, process completed, rejected, backout, closed) + - an installment EMI has already settled keeps the flag: there it is no longer + a portal block (a paid installment is never offered) but the record that this + installment was EMI funded, and the events that trail a disbursal must not + erase it - written with db.set_value so it persists regardless of the parent's save path - a payload without udf_3 is left alone rather than guessed at """ @@ -262,6 +266,9 @@ def set_term_emi_flag(doc, payment_term, event): in_flight = 1 if event in EMI_IN_FLIGHT_EVENTS else 0 for schedule in doc.payment_schedule: if str(schedule.payment_term) == str(payment_term): + if not in_flight and flt(schedule.outstanding) <= 0: + return + db.set_value( "Payment Schedule", schedule.name, "is_emi_payment", in_flight, update_modified=False ) From af5ee005571f1b809345032f71634f7e24e83d9b Mon Sep 17 00:00:00 2001 From: Samarth Pansare Date: Tue, 18 Aug 2026 13:39:50 +0530 Subject: [PATCH 16/16] fix: treat a disbursal as funding rather than as a terminal event emi.disbursed fell outside EMI_IN_FLIGHT_EVENTS, so the flag was cleared on arrival and set again a moment later once the installment settled. The intermediate write served nothing: from the moment GrayQuest hands over the money, collecting the installment from the parent again is the worse error, so the flag is now set on the disbursal itself and stays set even if the settlement that follows fails. A rejection or backout also no longer clears the fee level flag once EMI has funded an installment. Money moved, and Fees.is_emi_payment drives both the EMI details tab and the payment plan discount gating - clearing it on a later application's backout misreads the fee and can re-apply a discount to an amount EMI already funded in full. --- grayquest/utils/__init__.py | 6 +++++ grayquest/utils/webhook.py | 48 +++++++++++++++++++++++++++---------- 2 files changed, 41 insertions(+), 13 deletions(-) diff --git a/grayquest/utils/__init__.py b/grayquest/utils/__init__.py index 3bec437..0d2f71a 100644 --- a/grayquest/utils/__init__.py +++ b/grayquest/utils/__init__.py @@ -28,6 +28,12 @@ "emi.downpayment.captured", } +# The event on which GrayQuest hands over the money. The installment it names is +# EMI funded from here on, whether or not the settlement that follows succeeds. +EMI_FUNDED_EVENTS = { + "emi.disbursed", +} + # Events after which the application can no longer fund anything. EMI_UNSUCCESSFUL_EVENTS = { "emi.rejected", diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index ce42e19..4c2c547 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -1,8 +1,9 @@ import frappe from frappe import _, db, get_doc, response -from frappe.utils import flt, get_datetime, now_datetime +from frappe.utils import cint, flt, get_datetime, now_datetime from grayquest.utils import ( + EMI_FUNDED_EVENTS, EMI_IN_FLIGHT_EVENTS, EMI_STATUS_MAPPING, EMI_UNSUCCESSFUL_EVENTS, @@ -175,7 +176,7 @@ def handle_emi_webhook(data): doc.emi_application_code = application_code if event == "emi.process.completed" or event == "emi.disbursed": doc.is_emi_payment = 1 - elif event in EMI_UNSUCCESSFUL_EVENTS: + elif event in EMI_UNSUCCESSFUL_EVENTS and not has_emi_funded_installment(doc): doc.is_emi_payment = 0 # Mark/clear the blocking flag on the installment this application belongs to @@ -241,9 +242,27 @@ def handle_response_web_form(data): frappe.log_error(f"{doctype} {docname} does not exist") +def has_emi_funded_installment(doc): + """ + Whether EMI has already settled an installment on this fee. + + Args: + doc (Document): Fees document + + Details: + - a rejection or backout on a later application must not clear the fee level + flag once EMI has funded an installment: money moved, and the flag drives + the EMI details tab as well as the payment plan discount gating + """ + return any( + cint(schedule.get("is_emi_payment")) and flt(schedule.outstanding) <= 0 + for schedule in doc.payment_schedule + ) + + def set_term_emi_flag(doc, payment_term, event): """ - Mark or clear the EMI blocking flag on a single installment. + Mark or clear the EMI flag on a single installment. Args: doc (Document): Fees document @@ -251,28 +270,31 @@ def set_term_emi_flag(doc, payment_term, event): event (str): Webhook event Details: - - the flag is set while the application is in flight, and cleared on every - terminal event (disbursed, process completed, rejected, backout, closed) - - an installment EMI has already settled keeps the flag: there it is no longer - a portal block (a paid installment is never offered) but the record that this - installment was EMI funded, and the events that trail a disbursal must not - erase it + - the flag is set while the application is in flight, and again on the + disbursal: from the moment GrayQuest hands over the money, collecting the + installment from the parent again is the worse error, so it stays set even + if the settlement that follows fails + - it is cleared on the terminal events that fund nothing (rejected, backout), + but never on an installment that has already been settled - there the flag + is no longer a portal block (a paid installment is never offered) but the + record that this installment was EMI funded, and the process completed and + closed events that trail a disbursal must not erase it - written with db.set_value so it persists regardless of the parent's save path - a payload without udf_3 is left alone rather than guessed at """ if not payment_term: return - in_flight = 1 if event in EMI_IN_FLIGHT_EVENTS else 0 + flag = 1 if event in EMI_IN_FLIGHT_EVENTS or event in EMI_FUNDED_EVENTS else 0 for schedule in doc.payment_schedule: if str(schedule.payment_term) == str(payment_term): - if not in_flight and flt(schedule.outstanding) <= 0: + if not flag and flt(schedule.outstanding) <= 0: return db.set_value( - "Payment Schedule", schedule.name, "is_emi_payment", in_flight, update_modified=False + "Payment Schedule", schedule.name, "is_emi_payment", flag, update_modified=False ) - schedule.is_emi_payment = in_flight + schedule.is_emi_payment = flag # set_value only busts the child row's cache, but the payment portal reads # the parent through get_cached_doc - without this it serves stale rows. frappe.clear_document_cache("Fees", doc.name)