feat: add composite seller PAN verification, name matching, and GST pull endpoint
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 30s
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 30s
This commit is contained in:
parent
926b6edabb
commit
9e60dc2f56
4 changed files with 407 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Reference in a new issue