diff --git a/grayquest/api.py b/grayquest/api.py index a427ff6..d875238 100644 --- a/grayquest/api.py +++ b/grayquest/api.py @@ -40,18 +40,24 @@ def handle_payment_callback(**kwargs): frappe.local.response["location"] = "/" return - # Build return URL + # Build return URLs payment_hash = frappe.db.get_value("Payment Request", payment_request, "payment_hash") - return_url = f"/payment?payment_request={payment_hash}" if payment_hash else "/" + base_url = frappe.utils.get_url() + if payment_hash: + success_url = f"{base_url}/tgaa-connect/payment-status?status=success&payment_request={payment_hash}" + failure_url = f"{base_url}/tgaa-connect/payment-status?status=failure&payment_request={payment_hash}" + else: + success_url = failure_url = "/" # Skip if already paid if frappe.db.get_value("Payment Request", payment_request, "status") == "Paid": frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = return_url + frappe.local.response["location"] = success_url return # Security: Require application_code to prevent URL tampering # GrayQuest always sends application_code on successful payment + payment_succeeded = False if status == "success" and application_code: frappe.db.set_value("Payment Request", payment_request, "transaction_id", application_code) doc = frappe.get_doc("Payment Request", payment_request) @@ -64,9 +70,10 @@ def handle_payment_callback(**kwargs): doc.on_payment_authorized(status="Completed") frappe.db.commit() + payment_succeeded = True frappe.local.response["type"] = "redirect" - frappe.local.response["location"] = return_url + frappe.local.response["location"] = success_url if payment_succeeded else failure_url except Exception as e: frappe.log_error(title="GrayQuest Callback Error", message=frappe.get_traceback()) diff --git a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py index 7fa1580..8e1f33f 100644 --- a/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py +++ b/grayquest/grayquest/doctype/grayquest_settings/grayquest_settings.py @@ -5,6 +5,7 @@ import frappe import requests from frappe import _, db, response +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 @@ -41,8 +42,7 @@ def get_payment_url(self, **kwargs): frappe.logger("grayquest").exception(frappe.get_traceback()) return url - def generate_url(self,kwargs): - headers = self.get_headers() + def generate_url(self, kwargs): payload = get_payload(self, kwargs) api_url = self.api_url.strip("/") slug = self.slug @@ -50,42 +50,121 @@ def generate_url(self,kwargs): slug = self.event_slug endpoint = f"{api_url}/v1/pp/redirect/{slug}" - response = requests.post(endpoint, headers=headers, json=payload) + 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 response.status_code == 201: return response.json().get("data", {}).get("redirection_url") else: - frappe.log_error(_("GrayQuest Payment Gateway Error"), response.json()) - return response.json().get("message") + 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 Key, Client ID, and Client Secret are required")) + + 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() + + 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, + ) + + 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() - # Encode client_id and client_secret in base64 - credentials = f"{self.client_id}:{client_secret}" - auth_token = base64.b64encode(credentials.encode()).decode() + 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, + ) - # Headers - return { - "Authorization": f"Basic {auth_token}", - "GQ-API-Key": api_key, - "Content-Type": "application/json", - } + 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) - if data.get("entity") == "direct": - return handle_payment_gateway_webhook(data) - elif data.get("entity") == "monthly-emi": - return handle_emi_webhook(data) - else: - frappe.log_error(_("Invalid Webhook Entity")) - return { - "status": "error", - "message": _("Invalid Webhook Entity"), - } + + try: + if data.get("entity") == "direct": + result = handle_payment_gateway_webhook(data) + elif data.get("entity") == "monthly-emi": + result = handle_emi_webhook(data) + else: + frappe.log_error(_("Invalid Webhook Entity")) + return {"status": "error", "message": _("Invalid Webhook Entity")} + return result + except Exception: + raise def add_webhook_log(self, data): add_webhook_log(data) @@ -97,13 +176,19 @@ 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} - res = requests.get(endpoint, headers=headers, params=payload) - - if res.status_code == 200: - response["message"] = res.json() - else: + params = {"application_code": transaction_id} + + res = self.make_request( + method="GET", + endpoint=endpoint, + params=params, + reference_doctype="Payment Request", + reference_docname=payment_request, + request_description="GrayQuest Payment Status Check", + ) + + if res.status_code != 200: frappe.log_error(_("GrayQuest Payment Gateway Error"), res.json()) - response["message"] = res.json() + + response["message"] = res.json() diff --git a/grayquest/utils/test_utils.py b/grayquest/utils/test_utils.py new file mode 100644 index 0000000..076a0d4 --- /dev/null +++ b/grayquest/utils/test_utils.py @@ -0,0 +1,240 @@ +from unittest.mock import MagicMock, patch + +from frappe.tests.utils import FrappeTestCase + +from grayquest.utils.utils import _get_fee_header_for_grade, _get_ticket_fee_headers + + +def _make_seat(allocated_student, student_grade): + seat = MagicMock() + seat.allocated_student = allocated_student + seat.student_grade = student_grade + return seat + + +def _make_grade_detail(grade, payment_gateway, payment_gateway_name, fee_header): + row = MagicMock() + row.grade = grade + row.payment_gateway = payment_gateway + row.payment_gateway_name = payment_gateway_name + row.fee_header = fee_header + return row + + +def _make_ticket( + event="EVT-001", + amount=1300.0, + payment_gateway_type="GrayQuest Settings", + payment_gateway_account="TGCH - Event", + seats=None, + seat_pricing_total=0.0, + free_seats_count=0, + chargeable_seats_count=0, + name="TKT-TEST-001", +): + ticket = MagicMock() + ticket.name = name + ticket.event = event + ticket.amount_after_discount = amount + ticket.payment_gateway_type = payment_gateway_type + ticket.payment_gateway_account = payment_gateway_account + ticket.seats = seats or [] + ticket.seat_pricing_total = seat_pricing_total + ticket.free_seats_count = free_seats_count + ticket.chargeable_seats_count = chargeable_seats_count + return ticket + + +class TestGetFeeHeaderForGrade(FrappeTestCase): + """Unit tests for _get_fee_header_for_grade.""" + + def _make_event(self, grade_details): + event = MagicMock() + event.grade_details = grade_details + return event + + def test_returns_fee_header_when_grade_and_gateway_match(self): + event = self._make_event([ + _make_grade_detail( + grade="Grade 7-TGAA", + payment_gateway="GrayQuest Settings", + payment_gateway_name="TGCH - Event", + fee_header="Walnut_Grade7_Fee", + ) + ]) + result = _get_fee_header_for_grade( + event, "Grade 7-TGAA", "GrayQuest Settings", "TGCH - Event" + ) + self.assertEqual(result, "Walnut_Grade7_Fee") + + def test_returns_none_when_grade_does_not_match(self): + event = self._make_event([ + _make_grade_detail( + grade="Grade 8-TGAA", + payment_gateway="GrayQuest Settings", + payment_gateway_name="TGCH - Event", + fee_header="Walnut_Grade8_Fee", + ) + ]) + result = _get_fee_header_for_grade( + event, "Grade 7-TGAA", "GrayQuest Settings", "TGCH - Event" + ) + self.assertIsNone(result) + + def test_returns_none_when_gateway_does_not_match(self): + event = self._make_event([ + _make_grade_detail( + grade="Grade 7-TGAA", + payment_gateway="Razorpay Merchant Settings", + payment_gateway_name="TGCH - Event", + fee_header="Razorpay_Grade7_Fee", + ) + ]) + result = _get_fee_header_for_grade( + event, "Grade 7-TGAA", "GrayQuest Settings", "TGCH - Event" + ) + self.assertIsNone(result) + + def test_returns_none_when_grade_details_empty(self): + event = self._make_event([]) + result = _get_fee_header_for_grade( + event, "Grade 7-TGAA", "GrayQuest Settings", "TGCH - Event" + ) + self.assertIsNone(result) + + +class TestGetTicketFeeHeaders(FrappeTestCase): + """Unit tests for _get_ticket_fee_headers — the function that builds the + fee_headers dict sent to Grayquest.""" + + def _make_event(self, grade_details, student_fee=1000.0): + event = MagicMock() + event.grade_details = grade_details + event.student_fee = student_fee + return event + + @patch("grayquest.utils.utils.frappe") + def test_single_grade_returns_grade_specific_fee_header(self, mock_frappe): + """When all seats belong to one grade and a matching grade_detail row exists, + fee_headers should use the configured fee_header as the key.""" + seats = [ + _make_seat("STU-001", "Grade 7-TGAA"), + _make_seat("STU-001", "Grade 7-TGAA"), + ] + ticket = _make_ticket(seats=seats, amount=1300.0) + + mock_frappe.get_doc.return_value = self._make_event([ + _make_grade_detail( + grade="Grade 7-TGAA", + payment_gateway="GrayQuest Settings", + payment_gateway_name="TGCH - Event", + fee_header="Walnut_Grade7_Fee", + ) + ]) + mock_frappe.utils.flt = __import__("frappe").utils.flt + + result = _get_ticket_fee_headers(ticket, {}) + + self.assertEqual(result, {"Walnut_Grade7_Fee": 1300.0}) + + @patch("grayquest.utils.utils.frappe") + def test_single_grade_falls_back_when_no_matching_grade_detail(self, mock_frappe): + """When no grade_detail row matches the grade + gateway combination, + fee_headers should fall back to total_payable / current_payable.""" + seats = [_make_seat("STU-001", "Grade 7-TGAA")] + ticket = _make_ticket(seats=seats, amount=1300.0) + + # Event has grade_details for a different gateway + mock_frappe.get_doc.return_value = self._make_event([ + _make_grade_detail( + grade="Grade 7-TGAA", + payment_gateway="Razorpay Merchant Settings", + payment_gateway_name="TGCH - Event", + fee_header="Razorpay_Grade7_Fee", + ) + ]) + mock_frappe.utils.flt = __import__("frappe").utils.flt + + result = _get_ticket_fee_headers(ticket, {}) + + self.assertEqual(result, {"total_payable": 1300.0, "current_payable": 1300.0}) + + @patch("grayquest.utils.utils.frappe") + def test_falls_back_when_no_seats(self, mock_frappe): + """When the ticket has no seats, fee_headers falls back to total_payable / current_payable.""" + ticket = _make_ticket(seats=[], amount=500.0) + mock_frappe.utils.flt = __import__("frappe").utils.flt + + result = _get_ticket_fee_headers(ticket, {}) + + self.assertEqual(result, {"total_payable": 500.0, "current_payable": 500.0}) + + @patch("grayquest.utils.utils.frappe") + def test_multi_grade_returns_per_grade_fee_headers(self, mock_frappe): + """When seats span two different grades, each grade should get its own + fee_header key using the corrected seat/count fields on the Ticket.""" + seats = [ + _make_seat("STU-001", "Grade 7-TGAA"), # grade 7 student + _make_seat("STU-002", "Grade 8-TGAA"), # grade 8 student + ] + ticket = _make_ticket( + seats=seats, + amount=2000.0, + seat_pricing_total=0.0, + free_seats_count=2, + chargeable_seats_count=0, + ) + + event = self._make_event( + grade_details=[ + _make_grade_detail( + grade="Grade 7-TGAA", + payment_gateway="GrayQuest Settings", + payment_gateway_name="TGCH - Event", + fee_header="Walnut_Grade7_Fee", + ), + _make_grade_detail( + grade="Grade 8-TGAA", + payment_gateway="GrayQuest Settings", + payment_gateway_name="TGCH - Event", + fee_header="Walnut_Grade8_Fee", + ), + ], + student_fee=1000.0, + ) + mock_frappe.get_doc.return_value = event + # Each student has 1 seat allocated + mock_frappe.db.count.return_value = 1 + mock_frappe.utils.flt = __import__("frappe").utils.flt + + result = _get_ticket_fee_headers(ticket, {}) + + # Each grade: 1 student × ₹1000 student_fee + 0 seat charges = ₹1000 + self.assertIn("Walnut_Grade7_Fee", result) + self.assertIn("Walnut_Grade8_Fee", result) + self.assertAlmostEqual(result["Walnut_Grade7_Fee"], 1000.0) + self.assertAlmostEqual(result["Walnut_Grade8_Fee"], 1000.0) + + @patch("grayquest.utils.utils.frappe") + def test_multi_grade_falls_back_when_no_grade_details_configured(self, mock_frappe): + """When multi-grade seats exist but no grade_details rows are configured, + the result falls back to total_payable / current_payable.""" + seats = [ + _make_seat("STU-001", "Grade 7-TGAA"), + _make_seat("STU-002", "Grade 8-TGAA"), + ] + ticket = _make_ticket( + seats=seats, + amount=2000.0, + seat_pricing_total=0.0, + free_seats_count=2, + chargeable_seats_count=0, + ) + + mock_frappe.get_doc.return_value = self._make_event(grade_details=[], student_fee=1000.0) + mock_frappe.db.count.return_value = 1 + mock_frappe.utils.flt = __import__("frappe").utils.flt + + result = _get_ticket_fee_headers(ticket, {}) + + self.assertEqual(result, {"total_payable": 2000.0, "current_payable": 2000.0}) diff --git a/grayquest/utils/utils.py b/grayquest/utils/utils.py index 69ded30..3bff870 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. @@ -48,24 +59,26 @@ def get_payload(controller, data): # Handle Ticket payments if doctype == "Ticket": - payload = _get_event_ticket_payload(ref_doc, data) + payload = _get_event_ticket_payload(controller, ref_doc, data) else: payload = _get_student_payment_payload(controller, ref_doc, data) return payload -def _get_event_ticket_payload(ticket_doc, data): +def _get_event_ticket_payload(controller, ticket_doc, data): """ Constructs payload for Event Ticket payments. """ - # Get customer details from ticket guardian = frappe.get_doc("Guardian", ticket_doc.customer) surl = data.get("success_url") furl = data.get("failure_url") + + student_details = _get_ticket_student_details(controller, ticket_doc) + 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), @@ -75,9 +88,43 @@ def _get_event_ticket_payload(ticket_doc, data): "error_url": furl or f"{get_url()}/walsh/events", }, } + + if student_details: + payload["student_details"] = student_details + return payload +def _get_ticket_student_details(controller, ticket_doc): + """ + Build student_details for a Ticket. + GrayQuest requires a single dict — always returns the first allocated student's details. + """ + if not hasattr(ticket_doc, 'seats') or not ticket_doc.seats: + return None + + seen = set() + student_ids = [] + for seat in ticket_doc.seats: + if seat.allocated_student and seat.allocated_student not in seen: + seen.add(seat.allocated_student) + student_ids.append(seat.allocated_student) + + if not student_ids: + return None + + details_list = [] + for student_id in student_ids: + if frappe.db.exists("Student", student_id): + student = frappe.get_doc("Student", student_id) + details_list.append(get_student_details(controller, student)) + + if not details_list: + return None + + return details_list[0] + + def _get_student_payment_payload(controller, ref_doc, data): """ Constructs payload for Student/Guardian payments. @@ -105,21 +152,27 @@ 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, "notes": get_notes(ref_doc, data), - "udf_details": {"udf_1": ref_doc.doctype, "udf_2": ref_doc.name}, + "udf_details": { + "udf_1": ref_doc.doctype, + "udf_2": ref_doc.name, + "udf_3": getattr(ref_doc, "reference_doctype", None), + "udf_4": getattr(ref_doc, "reference_name", None), + "udf_5": getattr(ref_doc, "payment_term", None), + }, "redirection": { - "success_url": data.get("success_url") or f"{url}/grayquest-payment", - "error_url": data.get("failure_url") or f"{url}/grayquest-payment", + "success_url": data.get("success_url") or f"{url}/tgaa-connect/payment-status?status=success&payment_request={getattr(ref_doc, 'payment_hash', '') or ''}", + "error_url": data.get("failure_url") or f"{url}/tgaa-connect/payment-status?status=failure&payment_request={getattr(ref_doc, 'payment_hash', '') or ''}", }, } return payload @@ -136,7 +189,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") @@ -145,22 +198,27 @@ def get_student_details(controller, student): sequence = frappe.get_value("Program", student.program, "sequence") # Construct the student details dictionary + # Sanitize all name fields — GrayQuest API rejects non-alphabets and empty values 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 + sanitized_first = _sanitize_alpha(student.first_name) + if sanitized_first: + student_details["student_first_name"] = sanitized_first + sanitized_middle = _sanitize_alpha(student.middle_name) + if sanitized_middle: + student_details["student_middle_name"] = sanitized_middle + sanitized_last = _sanitize_alpha(student.last_name) + if sanitized_last: + student_details["student_last_name"] = sanitized_last 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" @@ -184,15 +242,19 @@ def get_customer_details(guardian): dict: A dictionary containing the customer details. """ # Construct the customer details dictionary + # Sanitize all name fields — GrayQuest API rejects non-alphabets and empty values 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 + sanitized_first = _sanitize_alpha(guardian.first_name) + if sanitized_first: + customer_details["customer_first_name"] = sanitized_first + sanitized_middle = _sanitize_alpha(guardian.middle_name) + if sanitized_middle: + customer_details["customer_middle_name"] = sanitized_middle + sanitized_last = _sanitize_alpha(guardian.last_name) + if sanitized_last: + customer_details["customer_last_name"] = sanitized_last if guardian.email_address: - customer_details["customer_email"] = guardian.email_address + customer_details["customer_email"] = guardian.email_address.strip() return customer_details @@ -226,7 +288,7 @@ def get_fee_headers(doc, data): if doctype in doctype_fields: total_field, current_field = doctype_fields[doctype] - if doctype in ["Event Participant", "Student Applicant"]: + if doctype in ["Event Participant", "Student Applicant", "Fees"]: total = current = getattr(doc, current_field, 0) else: # For other document types, fetch referenced document @@ -284,8 +346,8 @@ def _get_ticket_fee_headers(ticket_doc, data): event = frappe.get_doc("Event Listing", ticket_doc.event) # Get selected payment gateway from ticket - selected_gateway = ticket_doc.custom_selected_payment_gateway - selected_gateway_name = ticket_doc.custom_selected_payment_gateway_name + selected_gateway = ticket_doc.payment_gateway_type + selected_gateway_name = ticket_doc.payment_gateway_account # Check if all students are in same grade unique_grades = list(students_by_grade.keys()) @@ -319,7 +381,7 @@ def _get_ticket_fee_headers(ticket_doc, data): for grade, amount in grade_breakdown.items(): fee_header_name = _get_fee_header_for_grade(event, grade, selected_gateway, selected_gateway_name) if fee_header_name: - fee_headers[fee_header_name] = amount + fee_headers[fee_header_name] = fee_headers.get(fee_header_name, 0) + amount has_fee_headers = True # If we successfully added fee headers, return them without total/current payable @@ -370,7 +432,7 @@ def _calculate_grade_breakdown(ticket_doc, students_by_grade): event = frappe.get_doc("Event Listing", ticket_doc.event) student_fee_per_student = event.student_fee or 0 - total_seat_charges = ticket_doc.custom_seat_pricing_total or 0 + total_seat_charges = ticket_doc.seat_pricing_total or 0 grade_breakdown = {} @@ -391,7 +453,7 @@ def _calculate_grade_breakdown(ticket_doc, students_by_grade): }) grade_seat_count += student_seat_count - total_seats = ticket_doc.custom_free_seats_count + ticket_doc.custom_chargeable_seats_count + total_seats = ticket_doc.free_seats_count + ticket_doc.chargeable_seats_count # Proportional seat charges if total_seats > 0: @@ -503,28 +565,34 @@ def _get_student_applicant_details(controller, applicant): """ student_details = {} - # Parse name from full name field + # Parse name from full name field and sanitize for GrayQuest API 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] + sanitized_first = _sanitize_alpha(name_parts[0]) + if sanitized_first: + student_details["student_first_name"] = sanitized_first if len(name_parts) > 1: - student_details["student_last_name"] = " ".join(name_parts[1:]) + sanitized_last = _sanitize_alpha(" ".join(name_parts[1:])) + if sanitized_last: + student_details["student_last_name"] = sanitized_last # Student Applicant is always NEW student_details["student_type"] = "NEW" # Date of birth if applicant.date_of_birth: - student_details["student_dob"] = get_date_str(applicant.date_of_birth) + dob_str = get_date_str(applicant.date_of_birth) + if dob_str: + student_details["student_dob"] = dob_str # 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: @@ -567,9 +635,13 @@ 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] + sanitized_first = _sanitize_alpha(name_parts[0]) + if sanitized_first: + customer_details["customer_first_name"] = sanitized_first if len(name_parts) > 1: - customer_details["customer_last_name"] = " ".join(name_parts[1:]) + sanitized_last = _sanitize_alpha(" ".join(name_parts[1:])) + if sanitized_last: + customer_details["customer_last_name"] = sanitized_last # Try to get email customer_email = ( @@ -579,7 +651,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 diff --git a/grayquest/utils/webhook.py b/grayquest/utils/webhook.py index 32b69cc..a40cf58 100644 --- a/grayquest/utils/webhook.py +++ b/grayquest/utils/webhook.py @@ -1,10 +1,70 @@ import frappe from frappe import _, db, get_doc, response -from frappe.utils import get_datetime, now_datetime +from frappe.utils import flt, get_datetime, getdate, now_datetime, nowdate from grayquest.utils import EMI_STATUS_MAPPING +def ensure_mode_of_payment_exists(mode_name): + """Create Mode of Payment if it doesn't exist""" + if not frappe.db.exists("Mode of Payment", mode_name): + frappe.get_doc({ + "doctype": "Mode of Payment", + "mode_of_payment": mode_name, + "type": "General" + }).insert(ignore_permissions=True) + + +def _parse_webhook_date(date_str): + """Parse DD-MM-YYYY or DD-MM-YYYY HH:MM:SS to YYYY-MM-DD string. + Returns nowdate() if date_str is None or unparseable.""" + if not date_str: + return nowdate() + try: + parts = date_str.strip().split(" ")[0].split("-") + return str(getdate(f"{parts[2]}-{parts[1]}-{parts[0]}")) + except Exception: + return nowdate() + + +def resolve_payment_request(udf_details): + """Resolve Payment Request doctype and docname from udf_details. + + Primary: udf_1 (doctype) and udf_2 (docname). + Fallback: If udf_1/udf_2 is missing or the doc doesn't exist, look up Payment Request + using udf_3 (fee doctype), udf_4 (fee name), and udf_5 (payment term). + + Returns: + tuple: (doctype, docname, fee_type) where fee_type is udf_3 value (e.g. "one_time") + """ + doctype = udf_details.get("udf_1") + docname = udf_details.get("udf_2") + fee_type = udf_details.get("udf_3") + + try: + if doctype and docname and db.exists(doctype, docname): + return doctype, docname, fee_type + except Exception: + pass + + # Fallback: find Payment Request using fee details from udf_3/4/5 + fee_name = udf_details.get("udf_4") + payment_term = udf_details.get("udf_5") + if fee_name: + filters = { + "reference_doctype": "Fees", + "reference_name": fee_name, + "docstatus": 1, + } + if payment_term: + filters["payment_term"] = payment_term + pr_name = db.get_value("Payment Request", filters, "name", order_by="creation desc") + if pr_name: + return "Payment Request", pr_name, fee_type + + return doctype, docname, fee_type + + def handle_payment_gateway_webhook(data): """ Handle Payment Gateway Webhook @@ -19,93 +79,89 @@ def handle_payment_gateway_webhook(data): - If fee_type is `one_time`, call validate_one_time_payment on Student Applicant - If payment was already processed via callback, just acknowledge the webhook """ - try: - if data.get("event") == "dt.payment.captured": - # Extract udf_details from the data - udf_details = data.get("udf_details", {}) - # Get doctype and docname from udf_details - doctype = udf_details.get("udf_1") - docname = udf_details.get("udf_2") - fee_type = udf_details.get("udf_3") - # Extract application details from the data - application_details = data.get("application_details") - # Get application code from application details - application_code = application_details.get("code") - - # Check if already paid via callback - avoid re-processing - current_status = db.get_value(doctype, docname, "status") - if current_status == "Paid": - # Already processed via callback - just acknowledge webhook - response["message"] = _("Payment already processed via callback") - return - - # Not yet paid - process via webhook (existing behavior) - # Fetch the document using doctype and docname - doc = get_doc(doctype, docname) - # Get payment details from the data - payment_details = data.get("payment_details", {}) - amount = payment_details.get("amount") - # Update the transaction_id field in the document - if hasattr(doc, "transaction_id"): - doc.db_set("transaction_id", application_code) - if hasattr(doc, "paid_amount"): - doc.db_set("paid_amount", amount) - # Call the on_payment_authorized method on the document - if payment_details.get("status") == "PAID": - # Route based on fee_type for one-time payments - if fee_type == "one_time" and hasattr(doc, "reference_doctype") and doc.reference_doctype == "Student Applicant": - # Get Student Applicant and call validate_one_time_payment - applicant = frappe.get_doc("Student Applicant", doc.reference_name) - payment_data = { - "amount": doc.grand_total, - "transaction_id": application_code, - } - applicant.validate_one_time_payment(data=payment_data, payment_mode="Online") - response["message"] = _("One Time Fee Payment Captured") - elif doc.doctype == "Payment Request": - doc.on_payment_authorized(status="Completed") - response["message"] = _("Payment successfully captured and processed.") - else: - if hasattr(doc, "validate_payment"): - doc.validate_payment(payment_details) - else: - create_payment_entry(doc, amount=amount, transaction_id=application_code) - response["message"] = _("Payment successfully captured and processed.") + if data.get("event") == "dt.payment.captured": + udf_details = data.get("udf_details", {}) + doctype, docname, fee_type = resolve_payment_request(udf_details) + # Extract application details from the data + application_details = data.get("application_details", {}) + # Get application code from application details + application_code = application_details.get("code") - elif data.get("event") == "dt.payment.order.created": - udf_details = data.get("udf_details", {}) - # Get doctype and docname from udf_details - doctype = udf_details.get("udf_1") - docname = udf_details.get("udf_2") - # Fetch the document using doctype and docname - doc = get_doc(doctype, docname) - if hasattr(doc, "validate_payment_order_created"): - res = doc.validate_payment_order_created(data) - if res: - response["message"] = res - else: - response["message"] = _("Payment order created, awaiting completion.") + # Check if already paid via callback - avoid re-processing + current_status = db.get_value(doctype, docname, "status") if frappe.db.has_column(doctype, "status") else None + if current_status == "Paid": + # Already processed via callback - just acknowledge webhook + response["message"] = _("Payment already processed via callback") + return - elif data.get("event") == "dt.payment.failed": - udf_details = data.get("udf_details", {}) - # Get doctype and docname from udf_details - doctype = udf_details.get("udf_1") - docname = udf_details.get("udf_2") - doc = get_doc(doctype, docname) - if hasattr(doc, "validate_failed_payment"): - res = doc.validate_failed_payment(data) - if res: - response["message"] = res + # Not yet paid - process via webhook (existing behavior) + # Fetch the document using doctype and docname + doc = get_doc(doctype, docname) + # Get payment details from the data + payment_details = data.get("payment_details", {}) + amount = payment_details.get("amount") + # Update the transaction_id and reference_no fields in the document + if hasattr(doc, "transaction_id"): + doc.db_set("transaction_id", application_code) + if hasattr(doc, "paid_amount"): + doc.db_set("paid_amount", amount) + # Store bank_reference_id as reference_no on Payment Request + bank_reference_id = payment_details.get("bank_reference_id") + if bank_reference_id and hasattr(doc, "reference_no"): + doc.db_set("reference_no", bank_reference_id) + # Call the on_payment_authorized method on the document + if payment_details.get("status") == "PAID": + # Route based on fee_type for one-time payments + if fee_type == "one_time" and hasattr(doc, "reference_doctype") and doc.reference_doctype == "Student Applicant": + # Get Student Applicant and call validate_one_time_payment + applicant = frappe.get_doc("Student Applicant", doc.reference_name) + payment_data = { + "amount": doc.grand_total, + "transaction_id": application_code, + } + applicant.validate_one_time_payment(data=payment_data, payment_mode="Online") + response["message"] = _("One Time Fee Payment Captured") + elif doc.doctype == "Payment Request": + # Set mode of payment for GrayQuest PG payments + ensure_mode_of_payment_exists("GrayQuest") + doc.db_set("mode_of_payment", "GrayQuest") + doc.reload() # Refresh in-memory object for payment_entry() + # Use paid_on date from webhook as posting_date, fallback to today + frappe.flags.webhook_posting_date = _parse_webhook_date( + payment_details.get("paid_on") + ) + doc.on_payment_authorized(status="Completed") + response["message"] = _("Payment successfully captured and processed.") + else: + if hasattr(doc, "validate_payment"): + payment_details["application_details"] = application_details + doc.validate_payment(payment_details) else: - response["message"] = _("Payment failed. Please try again or contact support.") + posting_date = _parse_webhook_date(payment_details.get("paid_on")) + create_payment_entry(doc, amount=amount, posting_date=posting_date, reference_date=posting_date, transaction_id=application_code) + response["message"] = _("Payment successfully captured and processed.") - # Return success response - except Exception as e: - # Log the error and return error response - frappe.log_error( - f"Payment Gateway Webhook Error: {str(e)}", frappe.get_traceback() - ) - response["message"] = _("Error in Payment Gateway Webhook") + elif data.get("event") == "dt.payment.order.created": + udf_details = data.get("udf_details", {}) + doctype, docname, _fee_type = resolve_payment_request(udf_details) + doc = get_doc(doctype, docname) + if hasattr(doc, "validate_payment_order_created"): + res = doc.validate_payment_order_created(data) + if res: + response["message"] = res + else: + response["message"] = _("Payment order created, awaiting completion.") + + elif data.get("event") == "dt.payment.failed": + udf_details = data.get("udf_details", {}) + doctype, docname, _fee_type = resolve_payment_request(udf_details) + doc = get_doc(doctype, docname) + if hasattr(doc, "validate_failed_payment"): + res = doc.validate_failed_payment(data) + if res: + response["message"] = res + else: + response["message"] = _("Payment failed. Please try again or contact support.") def handle_emi_webhook(data): @@ -117,41 +173,151 @@ def handle_emi_webhook(data): Details: - the udf_details contains `Payment Request` doctype and docname + - If udf_details are missing (external webhook not generated by our system), + attempts to resolve the Payment Request using student_uuid, amount, and + academic_year from the webhook payload """ - 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") + udf_details = data.get("udf_details", {}) + doctype, docname, _fee_type = resolve_payment_request(udf_details) - # Extract application details from the webhook data - application_details = data.get("application_details") - application_code = application_details.get("code") + # Extract application details from the webhook data + application_details = data.get("application_details", {}) + application_code = application_details.get("code") + + # If udf_details are missing, try to resolve the Payment Request + if not doctype or not docname: + doctype, docname = resolve_payment_request_from_emi_webhook(data) + if not docname: + frappe.log_error( + "EMI Webhook: Could not resolve Payment Request", + frappe.as_json(data), + ) + response["message"] = _( + "EMI webhook received but could not resolve Payment Request" + ) + return + + # Store UTR as reference_no + disbursement_details = data.get("disbursement_details") or {} + utr = disbursement_details.get("utr") + + # Update the document with transaction ID, reference_no and EMI payment status + update_fields = {"transaction_id": application_code, "is_emi_payment": 1} + if utr: + update_fields["reference_no"] = utr + db.set_value(doctype, docname, update_fields) + + # Retrieve the document using doctype and docname + doc = get_doc(doctype, docname) + + # Update EMI status in the payment request + event = data.get("event") + timestamp = data.get("timestamp") + update_emi_status(doc, event, timestamp) - # Update the document with transaction ID and EMI payment status - db.set_value( - doctype, docname, {"transaction_id": application_code, "is_emi_payment": 1} + # If the event is 'emi.disbursed', mark the payment as authorized/completed + if event == "emi.disbursed": + # Check if already paid - avoid duplicate payment entry on duplicate webhook + if doc.status == "Paid": + response["message"] = _("EMI already processed") + return + + # Safety net: if late fee was wrongly added after EMI initiation, + # remove it before processing payment + fee_details = data.get("fee_details") or {} + disbursed_amount = flt( + disbursement_details.get("disbursed_amount") + or fee_details.get("amount") ) + if disbursed_amount and flt(doc.grand_total) != disbursed_amount: + current_user = frappe.session.user + try: + frappe.set_user("Administrator") + fees = frappe.get_doc("Fees", doc.reference_name) + fees.adjust_late_fee(0, payment_term=doc.payment_term) + doc.db_set("grand_total", disbursed_amount) + except Exception: + frappe.log_error( + title="EMI Disbursement: Late fee adjustment failed", + message=( + f"PR: {doc.name}, Fees: {doc.reference_name}, " + f"PR Grand Total: {doc.grand_total}, Disbursed: {disbursed_amount}" + ), + ) + finally: + frappe.set_user(current_user) - # Retrieve the document using doctype and docname - doc = get_doc(doctype, docname) + # Set mode of payment for GrayQuest EMI payments + ensure_mode_of_payment_exists("GrayQuest EMI") + doc.db_set("mode_of_payment", "GrayQuest EMI") + doc.reload() # Refresh in-memory object for payment_entry() + # Use disbursement date from webhook as posting_date, fallback to today + frappe.flags.webhook_posting_date = _parse_webhook_date( + disbursement_details.get("date") + ) + doc.on_payment_authorized(status="Completed") + response["message"] = _("EMI Disbursed") + return - # Update EMI status in the payment request - event = data.get("event") - timestamp = data.get("timestamp") - update_emi_status(doc, event, timestamp) + # Return success message for other events + response["message"] = _("EMI Status Updated") + + +def resolve_payment_request_from_emi_webhook(data): + """ + Resolve Payment Request when udf_details are missing (external EMI webhook). + + Matching strategy: + 1. Use student_uuid to find the Student in the system + 2. Find the latest submitted Fees for that student + academic_year + 3. Find an Initiated Payment Request linked to that Fees with matching amount + 4. Return (doctype, docname) if resolved, else (None, None) - # If the event is 'emi.disbursed', mark the payment as authorized/completed - if event == "emi.disbursed": - doc.on_payment_authorized(status="Completed") - response["message"] = _("EMI Disbursed") + Args: + data (dict): Full EMI webhook payload + + Returns: + tuple: ("Payment Request", docname) if resolved, else (None, None) + """ + student_details = data.get("student_details", {}) + fee_details = data.get("fee_details", {}) + disbursement_details = data.get("disbursement_details", {}) + + student_uuid = student_details.get("student_uuid") + amount = flt(disbursement_details.get("disbursed_amount") or fee_details.get("amount")) + academic_year = student_details.get("academic_year") + + if not student_uuid or not academic_year or not amount: + return None, None + + if not db.exists("Student", student_uuid): + return None, None + + # Step 1: Find the latest submitted Fees for this student and academic year + fees_filters = { + "student": student_uuid, + "academic_year": academic_year, + "docstatus": 1, + } + + fees_name = db.get_value("Fees", fees_filters, "name", order_by="creation desc") + if not fees_name: + return None, None + + # Step 2: Find Initiated Payment Request linked to this Fees with matching amount + pr_filters = { + "reference_doctype": "Fees", + "reference_name": fees_name, + "status": "Initiated", + "docstatus": 1, + "grand_total": amount, + } - # Return success message for other events - response["message"] = _("EMI Status Updated") - except Exception as e: - # Log the error and return an error message - frappe.log_error(f"EMI Webhook Error: {str(e)}", frappe.get_traceback()) - response["message"] = _("Error in EMI Webhook") + pr_name = db.get_value("Payment Request", pr_filters, "name", order_by="creation asc") + if pr_name: + return "Payment Request", pr_name + + return None, None def handle_response_web_form(data): @@ -211,15 +377,47 @@ def add_webhook_log(data): timestamp = get_datetime(timestamp) application_details = data.get("application_details", {}) application_code = application_details.get("code") - udf_details = data.get("udf_details", {}) - doctype = udf_details.get("udf_1") - docname = udf_details.get("udf_2") - if doctype == "Payment Request": - student = db.get_value(doctype, docname, "party") - elif frappe.db.has_column(doctype, "student"): - student = db.get_value(doctype, docname, "student") - else: - student = None + + # Resolve reference document: Payment Request > Fees > empty + doctype, docname = None, None + try: + udf_details = data.get("udf_details", {}) + resolved_dt, resolved_dn, _ = resolve_payment_request(udf_details) + if resolved_dt and resolved_dn: + doctype, docname = resolved_dt, resolved_dn + elif data.get("entity") == "monthly-emi": + resolved_dt, resolved_dn = resolve_payment_request_from_emi_webhook(data) + if resolved_dt and resolved_dn: + doctype, docname = resolved_dt, resolved_dn + else: + # Try to at least find the Fees document + student_uuid = (data.get("student_details") or {}).get("student_uuid") + academic_year = (data.get("student_details") or {}).get("academic_year") + if student_uuid and academic_year: + fees_name = db.get_value("Fees", { + "student": student_uuid, + "academic_year": academic_year, + "docstatus": 1, + }, "name", order_by="creation desc") + if fees_name: + doctype, docname = "Fees", fees_name + except Exception: + pass + + student = None + try: + if doctype and docname: + if doctype == "Payment Request": + student = db.get_value(doctype, docname, "party") + elif frappe.db.has_column(doctype, "student"): + student = db.get_value(doctype, docname, "student") + if not student: + student_uuid = (data.get("student_details") or {}).get("student_uuid") + if student_uuid and db.exists("Student", student_uuid): + student = student_uuid + except Exception: + pass + entity = data.get("entity") if entity == "direct": entity_type = "Payment Gateway" @@ -243,7 +441,9 @@ def add_webhook_log(data): } ) # Save the Webhook Log document - webhook_log.insert(ignore_permissions=True) + webhook_log.insert(ignore_permissions=True, ignore_links=True) + # Commit immediately to ensure log is saved even if payment processing fails later + frappe.db.commit() except Exception: # Log the error frappe.log_error("GrayQuest Webhook Log Error", frappe.get_traceback())