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_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/grayquest/doctype/grayquest_settings/grayquest_settings.py b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py index 8f7d6d9..d7ff6bf 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,34 +51,110 @@ 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: - 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") + + # 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.""" + return create_request_log( + data=data, + service_name=service_name, + url=url, + **kwargs, + ) - # Headers - return { - "Authorization": f"Basic {auth_token}", - "GQ-API-Key": api_key, - "Content-Type": "application/json", - } + 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 @@ -119,24 +195,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 +224,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}" @@ -178,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). @@ -193,51 +280,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"} @@ -245,8 +310,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/utils/__init__.py b/grayquest/utils/__init__.py index 4d30bd9..fc38ac2 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 = { @@ -17,4 +18,25 @@ "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", +} + +# 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", + "emi.backout", } \ No newline at end of file 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..b2047e1 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. @@ -65,7 +76,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 ""), "customer_details": get_customer_details(guardian), "fee_headers": get_fee_headers(ticket_doc, data), "notes": get_notes(ticket_doc, data), @@ -105,13 +116,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 ""), "fee_headers": get_fee_headers(ref_doc, data), "student_details": get_student_details(controller, student), "customer_details": customer_details, @@ -136,40 +147,39 @@ 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") - # 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 = {} - 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 + 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" - 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) + class_id = _get_class_id(controller, student.program) + if class_id is not None: + student_details["student_class_id"] = class_id + return student_details @@ -185,14 +195,17 @@ 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 + customer_details["customer_email"] = guardian.email_address.strip() return customer_details @@ -460,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) @@ -507,9 +520,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,21 +535,16 @@ 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 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: - 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 @@ -567,9 +578,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 = ( @@ -579,11 +593,20 @@ 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 +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. @@ -599,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 @@ -618,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}", @@ -630,9 +654,9 @@ 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(student), + "student_details": _get_student_details_minimal(controller, student, kwargs), "customer_details": customer_details, "notes": notes, "udf_details": { @@ -651,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: @@ -666,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.") @@ -688,14 +713,15 @@ 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 -def _get_student_details_minimal(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"} @@ -703,6 +729,14 @@ def _get_student_details_minimal(student): details["student_first_name"] = student.first_name if student.last_name: details["student_last_name"] = student.last_name + + 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 + return details @@ -714,7 +748,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 @@ -737,6 +771,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: @@ -746,7 +784,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,11 +796,11 @@ 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, - "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, @@ -779,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 c7633fe..c45d39b 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -1,8 +1,13 @@ import frappe from frappe import _, db, get_doc, response -from frappe.utils import get_datetime, now_datetime +from frappe.utils import cint, flt, get_datetime, now_datetime -from grayquest.utils import EMI_STATUS_MAPPING +from grayquest.utils import ( + EMI_FUNDED_EVENTS, + EMI_IN_FLIGHT_EVENTS, + EMI_STATUS_MAPPING, + EMI_UNSUCCESSFUL_EVENTS, +) def handle_payment_gateway_webhook(data): @@ -60,6 +65,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 +128,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) @@ -141,14 +160,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,19 +184,32 @@ 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 == "emi.process.completed" or event == "emi.disbursed": doc.is_emi_payment = 1 + 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 + 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"): + 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 {} 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") @@ -190,7 +225,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": @@ -201,7 +236,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") @@ -221,7 +256,66 @@ def handle_response_web_form(data): frappe.log_error(f"{doctype} {docname} does not exist") -def update_emi_status(doc, event, timestamp): +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 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 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 + + 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 flag and flt(schedule.outstanding) <= 0: + return + + db.set_value( + "Payment Schedule", schedule.name, "is_emi_payment", flag, update_modified=False + ) + 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) + return + + +def update_emi_status(doc, event, timestamp, payment_term=None): """ Update EMI Status in Payment Request or Fees @@ -229,6 +323,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() @@ -242,10 +337,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() @@ -275,7 +374,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.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 @@
{{ 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.
If you are not redirected automatically, click here.
+ {% endif %}