diff --git a/verifier/serializers.py b/verifier/serializers.py index a3738a0..ecb8598 100644 --- a/verifier/serializers.py +++ b/verifier/serializers.py @@ -20,6 +20,33 @@ class VerifyDocumentRequestSerializer(serializers.Serializer): ) +class VerifySellerPanGstRequestSerializer(serializers.Serializer): + """Input payload for verifying a seller's identity via PAN and pulling linked GSTINs.""" + seller_name = serializers.CharField( + required=True, + help_text="Full legal name of the seller / owner to match against the government PAN card.", + ) + pan = serializers.CharField( + required=True, + max_length=10, + min_length=10, + help_text="10-character Permanent Account Number (PAN).", + ) + gstin = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + max_length=15, + help_text="Optional GSTIN entered by seller to cross-verify against linked GST records.", + ) + preferred_vendor = serializers.CharField( + required=False, + allow_blank=True, + allow_null=True, + help_text="Optional vendor preference (defaults to waterfall order).", + ) + + class VendorAttemptLogSerializer(serializers.ModelSerializer): class Meta: model = VendorAttemptLog diff --git a/verifier/tests.py b/verifier/tests.py index 25dd328..c977dd4 100644 --- a/verifier/tests.py +++ b/verifier/tests.py @@ -4,7 +4,7 @@ from rest_framework import status from rest_framework.test import APIClient from verifier.engine.base import BaseVendor, VendorResult, VerificationStatus -from verifier.engine.registry import VendorRegistry +from verifier.engine.registry import VendorRegistry, registry from verifier.engine.orchestrator import VerificationEngine from verifier.vendors.codeplus import CodeplusVendor from verifier.models import VerificationTransaction, VendorAttemptLog @@ -618,4 +618,128 @@ class VerificationAPITests(TestCase): self.assertEqual(res_portal.status_code, status.HTTP_200_OK) self.assertContains(res_portal, "Test Execution Console") + def test_seller_pan_gst_verification_success(self): + """Test composite seller PAN verification, name matching, and GST pull.""" + from verifier.views import compare_names + # Test name matcher directly + m1 = compare_names("RAHUL KUMAR", "RAHUL KUMAR") + self.assertTrue(m1["matched"]) + self.assertEqual(m1["score"], 100.0) + + m2 = compare_names("Mr. Rahul Kumar", "RAHUL KUMAR") + self.assertTrue(m2["matched"]) + + m_diff = compare_names("PRIYA SHARMA", "ROHIT SHARMA") + self.assertFalse(m_diff["matched"]) + + # Test POST /api/verify/seller/ with mocked vendor responses + mock_pan_resp = { + "statusCode": 200, + "data": { + "name": "RAHUL KUMAR", + "pan_type": "Individual", + "status": "VALID", + "aadhaar_seeding_status": "LINKED", + }, + } + mock_gst_resp = { + "request_id": "req-gst-1", + "status": 200, + "data": { + "code": "1002", + "message": "Found 1 records", + "results": [ + { + "document_type": "GSTIN", + "document_id": "27ABCDE1234F1Z5", + "status": "Active", + "state": "Maharashtra", + "state_code": "27", + } + ], + }, + } + + class MockResp: + def __init__(self, d): + self.d = d + self.status_code = 200 + def json(self): + return self.d + + codeplus_vendor = registry.get_vendor("codeplus") + from unittest.mock import patch + with patch.object(codeplus_vendor.session, "post") as mock_post: + mock_post.side_effect = [MockResp(mock_pan_resp), MockResp(mock_gst_resp)] + + url = reverse("verifier:verify_seller_pan_gst") + res = self.client.post( + url, + { + "seller_name": "RAHUL KUMAR", + "pan": "ABCDE1234F", + "gstin": "27ABCDE1234F1Z5", + }, + format="json", + ) + + self.assertEqual(res.status_code, status.HTTP_200_OK) + self.assertTrue(res.data["is_success"]) + self.assertEqual(res.data["status"], "SUCCESS") + self.assertTrue(res.data["data"]["name_matched"]) + self.assertEqual(res.data["data"]["primary_gstin"], "27ABCDE1234F1Z5") + self.assertTrue(res.data["data"]["gstin_matched"]) + self.assertEqual(len(res.data["data"]["gst_records"]), 1) + + # Check DB persistence + tx = VerificationTransaction.objects.get(reference_id=res.data["reference_id"]) + self.assertEqual(tx.document_type, "SELLER_PAN_GST") + self.assertEqual(tx.status, "SUCCESS") + self.assertEqual(tx.final_data["verified_pan_name"], "RAHUL KUMAR") + + def test_seller_pan_gst_name_mismatch(self): + """Test seller PAN verification when entered seller name does not match PAN holder.""" + mock_pan_resp = { + "statusCode": 200, + "data": { + "name": "ROHIT SHARMA", + "pan_type": "Individual", + "status": "VALID", + }, + } + mock_gst_resp = {"status": 200, "data": {"code": "1011", "message": "No record found."}} + + class MockResp: + def __init__(self, d): + self.d = d + self.status_code = 200 + def json(self): + return self.d + + codeplus_vendor = registry.get_vendor("codeplus") + from unittest.mock import patch + with patch.object(codeplus_vendor.session, "post") as mock_post: + mock_post.side_effect = [MockResp(mock_pan_resp), MockResp(mock_gst_resp)] + + url = reverse("verifier:verify_seller_pan_gst") + res = self.client.post( + url, + { + "seller_name": "PRIYA VERMA", + "pan": "ABCDE1234F", + }, + format="json", + ) + + self.assertEqual(res.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + self.assertFalse(res.data["is_success"]) + self.assertEqual(res.data["status"], "NAME_MISMATCH") + self.assertFalse(res.data["data"]["name_matched"]) + self.assertIn("Name mismatch", res.data["error_message"]) + + # DB persistence check + tx = VerificationTransaction.objects.get(reference_id=res.data["reference_id"]) + self.assertEqual(tx.status, "NAME_MISMATCH") + + diff --git a/verifier/urls.py b/verifier/urls.py index 8c0ee5d..36cbf04 100644 --- a/verifier/urls.py +++ b/verifier/urls.py @@ -1,6 +1,7 @@ from django.urls import path from .views import ( DocumentVerifyView, + SellerPanGstVerifyView, VendorListView, TransactionDetailView, TransactionListView, @@ -11,6 +12,7 @@ app_name = "verifier" urlpatterns = [ path("verify/", DocumentVerifyView.as_view(), name="verify_document"), + path("verify/seller/", SellerPanGstVerifyView.as_view(), name="verify_seller_pan_gst"), path("vendors/", VendorListView.as_view(), name="list_vendors"), path("transactions/", TransactionListView.as_view(), name="transaction_list"), path("transactions//", TransactionDetailView.as_view(), name="transaction_detail"), diff --git a/verifier/views.py b/verifier/views.py index eda5623..ed5f22c 100644 --- a/verifier/views.py +++ b/verifier/views.py @@ -1,4 +1,7 @@ import logging +import re +import time +from difflib import SequenceMatcher from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView @@ -9,12 +12,78 @@ from verifier.engine import engine, registry from .models import VerificationTransaction, VendorAttemptLog from .serializers import ( VerifyDocumentRequestSerializer, + VerifySellerPanGstRequestSerializer, VerificationTransactionSerializer, ) logger = logging.getLogger(__name__) +def normalize_name(name: str) -> str: + """Normalizes names by stripping honorifics, titles, special characters, and excess whitespace.""" + if not name: + return "" + name = name.upper().strip() + for p in [r"\bMR\.?\b", r"\bMRS\.?\b", r"\bMS\.?\b", r"\bSHRI\b", r"\bSMT\.?\b", r"\bDR\.?\b", r"\bM/S\b"]: + name = re.sub(p, "", name, flags=re.IGNORECASE) + name = re.sub(r"[^A-Z\s]", " ", name) + return " ".join(name.split()) + + +def compare_names(name1: str, name2: str) -> dict: + """ + Intelligent name matching for Indian KYC. + Matches exact, permuted, initials, and fuzzy token sets while rejecting different first names with common surnames. + """ + norm1, norm2 = normalize_name(name1), normalize_name(name2) + if not norm1 or not norm2: + return {"matched": False, "score": 0.0, "reason": "Missing or empty name"} + if norm1 == norm2: + return {"matched": True, "score": 100.0, "reason": "Exact name match"} + + words1, words2 = norm1.split(), norm2.split() + # Reject complete first name mismatch (e.g. PRIYA SHARMA vs ROHIT SHARMA) + if len(words1) >= 2 and len(words2) >= 2: + if words1[0] != words2[0] and words1[-1] != words2[0] and words1[0] != words2[-1]: + if len(words1[0]) >= 3 and len(words2[0]) >= 3: + fn_sim = SequenceMatcher(None, words1[0], words2[0]).ratio() + if fn_sim < 0.65: + return { + "matched": False, + "score": round(fn_sim * 100, 1), + "reason": f"First name mismatch ('{words1[0]}' vs '{words2[0]}')", + } + + set1, set2 = set(words1), set(words2) + if set1 == set2: + return {"matched": True, "score": 98.0, "reason": "Permuted words match"} + + common = set1.intersection(set2) + if len(common) == min(len(set1), len(set2)): + return {"matched": True, "score": 95.0, "reason": "Subset token match"} + + # Initial check (e.g. 'RAHUL K' vs 'RAHUL KUMAR' or 'V SANKAR' vs 'VIGNESH SANKAR') + if len(words1) == len(words2): + initial_match = True + for w1, w2 in zip(words1, words2): + if w1 != w2 and not (len(w1) == 1 and w2.startswith(w1)) and not (len(w2) == 1 and w1.startswith(w2)): + initial_match = False + break + if initial_match: + return {"matched": True, "score": 92.0, "reason": "Initials abbreviation match"} + + sort_ratio = SequenceMatcher(None, " ".join(sorted(words1)), " ".join(sorted(words2))).ratio() * 100 + seq_ratio = SequenceMatcher(None, norm1, norm2).ratio() * 100 + best_score = max(sort_ratio, seq_ratio) + matched = best_score >= 80.0 + return { + "matched": matched, + "score": round(best_score, 1), + "reason": "Fuzzy token match" if matched else "Name similarity score below 80% threshold", + } + + + def mask_sensitive_payload(payload: dict) -> dict: """Mask sensitive inputs like full aadhaar numbers or passwords for audit safety.""" masked = dict(payload) @@ -106,6 +175,190 @@ class DocumentVerifyView(APIView): return Response(response_payload, status=http_status) +class SellerPanGstVerifyView(APIView): + """ + POST /api/verify/seller/ + Composite verification endpoint for seller onboarding: + 1. Verifies the seller's PAN against government / vendor records. + 2. Validates that the seller's entered full name matches the verified PAN cardholder name. + 3. Fetches all active GST registrations linked to that PAN. + 4. Automatically persists the full verification transaction and vendor attempt logs in the database. + """ + + def post(self, request, *args, **kwargs): + serializer = VerifySellerPanGstRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response( + {"is_success": False, "errors": serializer.errors}, + status=status.HTTP_400_BAD_REQUEST, + ) + + seller_name = serializer.validated_data["seller_name"].strip() + pan = serializer.validated_data["pan"].strip().upper() + entered_gstin = (serializer.validated_data.get("gstin") or "").strip().upper() + preferred_vendor = serializer.validated_data.get("preferred_vendor") + + start_time = time.time() + + # Step 1: Verify PAN via waterfall engine + pan_result = engine.verify( + document_type="pan", + data={"pan": pan}, + preferred_vendor=preferred_vendor, + ) + + all_attempts = list(pan_result.attempts) + + if not pan_result.is_success: + total_latency = (time.time() - start_time) * 1000 + tx = VerificationTransaction.objects.create( + document_type="SELLER_PAN_GST", + status="FAILED", + successful_vendor=None, + input_payload={"seller_name": seller_name, "pan": pan, "gstin": entered_gstin}, + final_data={}, + attempts_count=len(all_attempts), + total_latency_ms=total_latency, + ) + for att in all_attempts: + VendorAttemptLog.objects.create( + transaction=tx, + attempt_number=att.get("attempt_number", 1), + vendor_name=att.get("vendor_name", "unknown"), + status=att.get("status", "UNKNOWN"), + is_success=att.get("is_success", False), + data=att.get("data", {}), + raw_response=att.get("raw_response"), + error_code=att.get("error_code"), + error_message=att.get("error_message"), + latency_ms=att.get("latency_ms", 0.0), + ) + res_payload = { + "reference_id": str(tx.reference_id), + "is_success": False, + "status": "FAILED", + "document_type": "SELLER_PAN_GST", + "error_message": f"PAN verification failed: {pan_result.error_message}", + "name_matched": False, + "gst_records": [], + "attempts": all_attempts, + "total_latency_ms": round(total_latency, 2), + } + tx.response_payload = res_payload + tx.save(update_fields=["response_payload"]) + return Response(res_payload, status=status.HTTP_422_UNPROCESSABLE_ENTITY) + + # PAN is valid, extract verified name + pan_data = pan_result.data or {} + verified_pan_name = ( + pan_data.get("name") + or pan_data.get("full_name") + or pan_data.get("name_on_card") + or "" + ) + + # Step 2: Compare seller name with verified PAN cardholder name + match_result = compare_names(seller_name, verified_pan_name) + + # Step 3: Pull GST linked with the PAN from PAN number + gst_result = engine.verify( + document_type="fetch_gst", + data={"pan": pan}, + preferred_vendor=preferred_vendor, + ) + for att in gst_result.attempts: + att["attempt_number"] = len(all_attempts) + 1 + all_attempts.append(att) + + gst_records = [] + if gst_result.is_success and gst_result.data: + gst_records = gst_result.data.get("results") or gst_result.data.get("gstins") or [] + + # Find active GSTINs + active_gstins = [ + (g.get("document_id") or g.get("gstin")) + for g in gst_records + if isinstance(g, dict) and g.get("status", "").lower() in ["active", "valid"] + ] + primary_gstin = active_gstins[0] if active_gstins else ( + (gst_records[0].get("document_id") or gst_records[0].get("gstin")) if gst_records and isinstance(gst_records[0], dict) else None + ) + + gstin_matched = False + if entered_gstin: + gstin_matched = any( + (g.get("document_id") or g.get("gstin") or "").upper() == entered_gstin + for g in gst_records if isinstance(g, dict) + ) + + total_latency = (time.time() - start_time) * 1000 + overall_success = pan_result.is_success and match_result["matched"] + overall_status = "SUCCESS" if overall_success else "NAME_MISMATCH" + + combined_data = { + "pan_number": pan, + "verified_pan_name": verified_pan_name, + "seller_name": seller_name, + "name_matched": match_result["matched"], + "match_score": match_result["score"], + "match_reason": match_result["reason"], + "pan_details": pan_data, + "gst_records": gst_records, + "active_gstins": active_gstins, + "primary_gstin": primary_gstin, + "entered_gstin": entered_gstin, + "gstin_matched": gstin_matched, + } + + # Step 4: Persist verification transaction & attempt logs in database + tx = VerificationTransaction.objects.create( + document_type="SELLER_PAN_GST", + status=overall_status, + successful_vendor=pan_result.successful_vendor, + input_payload={"seller_name": seller_name, "pan": pan, "gstin": entered_gstin}, + final_data=combined_data, + attempts_count=len(all_attempts), + total_latency_ms=total_latency, + ) + + for att in all_attempts: + VendorAttemptLog.objects.create( + transaction=tx, + attempt_number=att.get("attempt_number", 1), + vendor_name=att.get("vendor_name", "unknown"), + status=att.get("status", "UNKNOWN"), + is_success=att.get("is_success", False), + data=att.get("data", {}), + raw_response=att.get("raw_response"), + error_code=att.get("error_code"), + error_message=att.get("error_message"), + latency_ms=att.get("latency_ms", 0.0), + ) + + error_msg = None + if not overall_success: + error_msg = f"Name mismatch: The entered name '{seller_name}' does not match PAN record '{verified_pan_name}' ({match_result['reason']})" + + res_payload = { + "reference_id": str(tx.reference_id), + "is_success": overall_success, + "status": overall_status, + "document_type": "SELLER_PAN_GST", + "successful_vendor": pan_result.successful_vendor, + "data": combined_data, + "attempts_count": len(all_attempts), + "attempts": all_attempts, + "error_message": error_msg, + "total_latency_ms": round(total_latency, 2), + } + + tx.response_payload = res_payload + tx.save(update_fields=["response_payload"]) + + http_code = status.HTTP_200_OK if overall_success else status.HTTP_422_UNPROCESSABLE_ENTITY + return Response(res_payload, status=http_code) + + class VendorListView(APIView): """ GET /api/vendors/