feat: add composite seller PAN verification, name matching, and GST pull endpoint
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 30s

This commit is contained in:
vickytechkey 2026-10-15 21:42:00 +05:30
parent 926b6edabb
commit 9e60dc2f56
4 changed files with 407 additions and 1 deletions

View file

@ -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

View file

@ -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")

View file

@ -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/<uuid:reference_id>/", TransactionDetailView.as_view(), name="transaction_detail"),

View file

@ -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/