documentverification/verifier/views.py
vickytechkey 4e60430b73
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 35s
feat: add document verification API views and core settings configurations
2026-10-16 13:41:43 +05:30

485 lines
20 KiB
Python

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
# Import engine and vendors to ensure default registration
import verifier.vendors # noqa: F401
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"}
# Strict 100% match requirement: names must match 100%
# If spaces/delimiters differ, e.g. "VIGNESHKRISHNASAMY" vs "VIGNESH KRISHNASAMY"
if "".join(norm1.split()) == "".join(norm2.split()):
return {"matched": True, "score": 100.0, "reason": "Exact name match (case & whitespace insensitive)"}
# Any non-100% similarity is rejected as name mismatch
words1, words2 = norm1.split(), norm2.split()
sort_ratio = SequenceMatcher(None, " ".join(sorted(words1)), " ".join(sorted(words2))).ratio() * 100
seq_ratio = SequenceMatcher(None, norm1, norm2).ratio() * 100
sim_score = round(max(sort_ratio, seq_ratio), 1)
return {
"matched": False,
"score": sim_score,
"reason": f"Name match score is {sim_score}%. Strict 100% match is required.",
}
def mask_sensitive_payload(payload: dict) -> dict:
"""Mask sensitive inputs like full aadhaar numbers or passwords for audit safety."""
masked = dict(payload)
sensitive_keys = ["aadhaar_number", "aadhaar", "otp", "password", "client_secret"]
for key in sensitive_keys:
if key in masked and isinstance(masked[key], str):
val = masked[key]
if len(val) > 4:
masked[key] = f"{val[:2]}****{val[-2:]}"
else:
masked[key] = "****"
return masked
class DocumentVerifyView(APIView):
"""
POST /api/verify/
Verifies a document using the vendor waterfall failover engine.
If the first vendor encounters an error/failure, the engine automatically
cascades to the next available vendor.
"""
def post(self, request, *args, **kwargs):
serializer = VerifyDocumentRequestSerializer(data=request.data)
if not serializer.is_valid():
return Response(
{"is_success": False, "errors": serializer.errors},
status=status.HTTP_400_BAD_REQUEST,
)
doc_type = serializer.validated_data["document_type"]
input_data = serializer.validated_data["data"]
preferred_vendor = serializer.validated_data.get("preferred_vendor")
files = request.FILES if hasattr(request, "FILES") else None
# Execute waterfall verification
engine_result = engine.verify(
document_type=doc_type,
data=input_data,
files=files,
preferred_vendor=preferred_vendor,
)
# Persist transaction and audit attempts in DB
safe_input = mask_sensitive_payload(input_data)
transaction = VerificationTransaction.objects.create(
document_type=doc_type,
status=engine_result.status.value,
successful_vendor=engine_result.successful_vendor,
input_payload=safe_input,
final_data=engine_result.data if engine_result.is_success else {},
attempts_count=len(engine_result.attempts),
total_latency_ms=engine_result.total_latency_ms,
)
# Persist attempt logs
for attempt in engine_result.attempts:
VendorAttemptLog.objects.create(
transaction=transaction,
attempt_number=attempt.get("attempt_number", 1),
vendor_name=attempt.get("vendor_name", "unknown"),
status=attempt.get("status", "UNKNOWN"),
is_success=attempt.get("is_success", False),
data=attempt.get("data", {}),
raw_response=attempt.get("raw_response"),
error_code=attempt.get("error_code"),
error_message=attempt.get("error_message"),
latency_ms=attempt.get("latency_ms", 0.0),
)
response_payload = {
"reference_id": str(transaction.reference_id),
"is_success": engine_result.is_success,
"status": engine_result.status.value,
"document_type": doc_type,
"successful_vendor": engine_result.successful_vendor,
"data": engine_result.data,
"attempts_count": len(engine_result.attempts),
"attempts": engine_result.attempts,
"error_message": engine_result.error_message,
"total_latency_ms": round(engine_result.total_latency_ms, 2),
}
# Store complete client response payload in database record
transaction.response_payload = response_payload
transaction.save(update_fields=["response_payload"])
http_status = status.HTTP_200_OK if engine_result.is_success else status.HTTP_422_UNPROCESSABLE_ENTITY
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
)
target_gstin = entered_gstin or primary_gstin or ""
gstin_matched = False
gst_details = {}
verified_gst_name = ""
gst_name_match = {"matched": True, "score": 100.0, "reason": "No GST provided"}
gst_error = None
if target_gstin:
# Check if entered GSTIN exists in fetched GST records for this PAN
matching_gst_record = None
for g in gst_records:
if isinstance(g, dict):
doc_id = (g.get("document_id") or g.get("gstin") or "").upper()
if doc_id == target_gstin:
matching_gst_record = g
gstin_matched = True
break
# Validate that 15-character GSTIN contains the 10-character PAN at chars 3-12
if len(target_gstin) == 15 and target_gstin[2:12].upper() != pan.upper():
gstin_matched = False
gst_error = f"GSTIN '{target_gstin}' does not match the provided PAN card '{pan}'."
# Check if matching record has legal_name
if matching_gst_record and matching_gst_record.get("legal_name"):
verified_gst_name = matching_gst_record.get("legal_name")
gst_details = matching_gst_record
else:
# Call gst_verify to get legal_name and active status
try:
gst_verify_result = engine.verify(
document_type="gst_verify",
data={"gst_number": target_gstin},
preferred_vendor=preferred_vendor,
)
for att in gst_verify_result.attempts:
att["attempt_number"] = len(all_attempts) + 1
all_attempts.append(att)
if gst_verify_result.is_success and gst_verify_result.data:
gst_details = gst_verify_result.data.get("gst_details") or gst_verify_result.data
verified_gst_name = (
gst_verify_result.data.get("legal_name")
or gst_details.get("legalName")
or gst_details.get("tradeName")
or ""
)
gstin_matched = True
except Exception as gst_err:
logger.warning("Optional gst_verify call failed: %s", gst_err)
# If verified_gst_name wasn't returned by upstream, fallback to matching record or verified_pan_name if PAN matches GSTIN
if not verified_gst_name and gstin_matched:
verified_gst_name = verified_pan_name
# Compare GST legal name against seller full name and verified PAN name
if verified_gst_name:
gst_name_match = compare_names(seller_name, verified_gst_name)
if not gst_name_match["matched"] and verified_pan_name:
pan_to_gst_match = compare_names(verified_pan_name, verified_gst_name)
if pan_to_gst_match["matched"]:
gst_name_match = pan_to_gst_match
total_latency = (time.time() - start_time) * 1000
pan_name_matched = bool(match_result.get("matched", False))
gst_name_matched = bool(gst_name_match.get("matched", False)) if target_gstin else True
if gst_error:
overall_success = False
overall_status = "GSTIN_PAN_MISMATCH"
error_msg = gst_error
elif not pan_name_matched:
overall_success = False
overall_status = "NAME_MISMATCH"
error_msg = f"Name mismatch: The entered name '{seller_name}' does not match PAN record '{verified_pan_name}' ({match_result.get('reason', '')})"
elif target_gstin and not gst_name_matched:
overall_success = False
overall_status = "GST_NAME_MISMATCH"
error_msg = f"Name mismatch: The entered name '{seller_name}' does not match GST legal name '{verified_gst_name}' ({gst_name_match.get('reason', '')})"
else:
overall_success = pan_result.is_success and pan_name_matched
overall_status = "SUCCESS" if overall_success else "FAILED"
error_msg = None
combined_data = {
"pan_number": pan,
"verified_pan_name": verified_pan_name,
"seller_name": seller_name,
"name_matched": pan_name_matched,
"pan_name_matched": pan_name_matched,
"pan_match_score": match_result.get("score", 0.0),
"pan_match_reason": match_result.get("reason", ""),
"pan_details": pan_data,
"gst_number": target_gstin,
"verified_gst_name": verified_gst_name,
"gst_name_matched": gst_name_matched,
"gst_match_score": gst_name_match.get("score", 0.0),
"gst_match_reason": gst_name_match.get("reason", ""),
"gst_details": gst_details,
"gst_records": gst_records,
"active_gstins": active_gstins,
"primary_gstin": primary_gstin,
"entered_gstin": entered_gstin,
"gstin_matched": gstin_matched,
"is_seller_verified": overall_success,
}
# 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),
)
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,
"is_seller_verified": overall_success,
"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/
Lists all active verification vendors registered with the engine in priority order.
"""
def get(self, request, *args, **kwargs):
vendors = registry.get_all_vendors()
vendor_data = [
{
"name": v.name,
"display_name": getattr(v, "display_name", v.name),
"priority": v.priority,
"is_active": v.is_active,
"supported_documents": v.supported_documents,
}
for v in vendors
]
return Response({"count": len(vendor_data), "vendors": vendor_data})
class TransactionDetailView(APIView):
"""
GET /api/transactions/<uuid:reference_id>/
Fetch complete audit trail and fallback logs for a transaction.
"""
def get(self, request, reference_id, *args, **kwargs):
try:
tx = VerificationTransaction.objects.prefetch_related("attempts").get(reference_id=reference_id)
except VerificationTransaction.DoesNotExist:
return Response(
{"error": f"Transaction '{reference_id}' not found"},
status=status.HTTP_404_NOT_FOUND,
)
serializer = VerificationTransactionSerializer(tx)
return Response(serializer.data)
class TransactionListView(APIView):
"""
GET /api/transactions/
Lists recent verification transactions (up to 50) for dashboard/portal monitoring.
"""
def get(self, request, *args, **kwargs):
limit = min(int(request.query_params.get("limit", 25)), 50)
txs = VerificationTransaction.objects.prefetch_related("attempts").all()[:limit]
serializer = VerificationTransactionSerializer(txs, many=True)
return Response({"count": len(serializer.data), "results": serializer.data})
from django.views.generic import TemplateView
class PortalView(TemplateView):
"""
Renders the interactive testing & inspection studio for all verification features.
"""
template_name = "verifier/portal.html"
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
vendors = registry.get_all_vendors()
context["vendors"] = vendors
context["active_vendor_count"] = sum(1 for v in vendors if v.is_active)
context["recent_transactions"] = VerificationTransaction.objects.prefetch_related("attempts").all()[:10]
return context