feat: add verification models, admin configuration, views, tests, and database migrations
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 30s

This commit is contained in:
vickytechkey 2026-10-17 13:50:16 +05:30
parent 0291bd6195
commit 418c7e681d
6 changed files with 342 additions and 2 deletions

View file

@ -1,5 +1,5 @@
from django.contrib import admin
from .models import VendorConfig, VerificationTransaction, VendorAttemptLog, PANDetails
from .models import VendorConfig, VerificationTransaction, VendorAttemptLog, PANDetails, SellerBankVerification
class VendorAttemptLogInline(admin.TabularInline):
@ -72,3 +72,24 @@ class PANDetailsAdmin(admin.ModelAdmin):
list_filter = ("category", "gender", "aadhaar_linked", "taxpayer_profile", "state", "created_at")
search_fields = ("document_id", "name", "email", "phone", "city", "pincode", "request_id")
readonly_fields = ("created_at", "updated_at")
@admin.register(SellerBankVerification)
class SellerBankVerificationAdmin(admin.ModelAdmin):
list_display = (
"seller_id",
"beneficiary_name",
"account_number",
"ifsc",
"account_ifsc",
"is_valid",
"status",
"vendor_name",
"tranx_id",
"latency_ms",
"updated_at",
)
list_filter = ("is_valid", "status", "vendor_name", "created_at")
search_fields = ("seller_id", "beneficiary_name", "account_number", "ifsc", "tranx_id")
readonly_fields = ("created_at", "updated_at")

View file

@ -0,0 +1,42 @@
# Generated by Django 6.1 on 2026-10-17 08:16
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('verifier', '0003_pandetails'),
]
operations = [
migrations.CreateModel(
name='SellerBankVerification',
fields=[
('seller_id', models.CharField(help_text='Unique Seller / Supplier ID', max_length=100, primary_key=True, serialize=False)),
('account_number', models.CharField(max_length=50)),
('ifsc', models.CharField(max_length=20)),
('account_ifsc', models.CharField(blank=True, max_length=20, null=True)),
('beneficiary_name', models.CharField(blank=True, max_length=255, null=True)),
('is_valid', models.BooleanField(default=False)),
('account_exists', models.BooleanField(default=False)),
('failure_reason', models.TextField(blank=True, null=True)),
('npci_error_code', models.CharField(blank=True, max_length=100, null=True)),
('tranx_id', models.CharField(blank=True, db_index=True, max_length=100, null=True)),
('timestamp', models.CharField(blank=True, max_length=100, null=True)),
('user_consent', models.CharField(default='true', max_length=50)),
('vendor_name', models.CharField(default='codeplus', max_length=50)),
('status', models.CharField(default='PENDING', max_length=50)),
('latency_ms', models.FloatField(default=0.0)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('transaction', models.ForeignKey(blank=True, help_text='Associated verification transaction', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='seller_bank_verifications', to='verifier.verificationtransaction')),
],
options={
'verbose_name': 'Seller Bank Verification',
'verbose_name_plural': 'Seller Bank Verifications',
'db_table': 'seller_bank_verification',
},
),
]

View file

@ -149,3 +149,44 @@ class PANDetails(models.Model):
def __str__(self):
return f"{self.document_id} - {self.name} ({self.city or 'Unknown'}, {self.state or 'Unknown'})"
class SellerBankVerification(models.Model):
"""
Dedicated bank verification record for seller onboarding and settlements,
keyed by seller_id as primary key.
"""
seller_id = models.CharField(max_length=100, primary_key=True, help_text="Unique Seller / Supplier ID")
transaction = models.ForeignKey(
VerificationTransaction,
related_name="seller_bank_verifications",
on_delete=models.SET_NULL,
null=True,
blank=True,
help_text="Associated verification transaction",
)
account_number = models.CharField(max_length=50)
ifsc = models.CharField(max_length=20)
account_ifsc = models.CharField(max_length=20, blank=True, null=True)
beneficiary_name = models.CharField(max_length=255, blank=True, null=True)
is_valid = models.BooleanField(default=False)
account_exists = models.BooleanField(default=False)
failure_reason = models.TextField(blank=True, null=True)
npci_error_code = models.CharField(max_length=100, blank=True, null=True)
tranx_id = models.CharField(max_length=100, blank=True, null=True, db_index=True)
timestamp = models.CharField(max_length=100, blank=True, null=True)
user_consent = models.CharField(max_length=50, default="true")
vendor_name = models.CharField(max_length=50, default="codeplus")
status = models.CharField(max_length=50, default="PENDING")
latency_ms = models.FloatField(default=0.0)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
db_table = "seller_bank_verification"
verbose_name = "Seller Bank Verification"
verbose_name_plural = "Seller Bank Verifications"
def __str__(self):
return f"Seller {self.seller_id} Bank: {self.beneficiary_name or self.account_number} ({'Valid' if self.is_valid else 'Invalid'})"

View file

@ -871,5 +871,71 @@ class VerificationAPITests(TestCase):
tx = VerificationTransaction.objects.get(reference_id=res.data["reference_id"])
self.assertEqual(tx.status, "NAME_MISMATCH")
def test_seller_bank_verification_success(self):
"""Test composite seller bank verification via /api/verify/seller/bank/ and DB persistence."""
from verifier.models import SellerBankVerification
mock_v5_response = {
"status": "success",
"result": {
"failure_reason": None,
"account_number": "********7270",
"user": None,
"is_valid": True,
"account_holder_name": "MANOJ PRASAD",
"account_ifsc": "UTIB0000373",
"npci_error_code": None,
},
"tranx_id": "9eeb9d58-04f8-4f8b-9608-99731850993f",
"timestamp": "2026-05-22T05:10:43.561554+00:00",
"chargeble": "true",
"user_consent": "true",
}
class MockResp:
status_code = 200
def json(self):
return mock_v5_response
codeplus_vendor = registry.get_vendor("codeplus")
from unittest.mock import patch
with patch.object(codeplus_vendor.session, "post", return_value=MockResp()):
url = reverse("verifier:verify_seller_bank")
res = self.client.post(
url,
{
"seller_id": "seller_1001",
"account_number": "123456787270",
"ifsc": "UTIB0000373",
"user_consent": "true",
},
format="json",
)
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertTrue(res.data["is_success"])
self.assertEqual(res.data["seller_id"], "seller_1001")
self.assertEqual(res.data["beneficiary_name"], "MANOJ PRASAD")
self.assertEqual(res.data["account_ifsc"], "UTIB0000373")
self.assertEqual(res.data["tranx_id"], "9eeb9d58-04f8-4f8b-9608-99731850993f")
# Verify DB persistence in SellerBankVerification table with seller_id as primary key
record = SellerBankVerification.objects.get(seller_id="seller_1001")
self.assertEqual(record.seller_id, "seller_1001")
self.assertEqual(record.account_number, "123456787270")
self.assertEqual(record.ifsc, "UTIB0000373")
self.assertEqual(record.account_ifsc, "UTIB0000373")
self.assertEqual(record.beneficiary_name, "MANOJ PRASAD")
self.assertTrue(record.is_valid)
self.assertTrue(record.account_exists)
self.assertIsNone(record.failure_reason)
self.assertIsNone(record.npci_error_code)
self.assertEqual(record.tranx_id, "9eeb9d58-04f8-4f8b-9608-99731850993f")
self.assertEqual(record.timestamp, "2026-05-22T05:10:43.561554+00:00")
self.assertEqual(record.user_consent, "true")
self.assertEqual(record.vendor_name, "codeplus")
self.assertEqual(record.status, "SUCCESS")
self.assertGreaterEqual(record.latency_ms, 0.0)

View file

@ -2,6 +2,7 @@ from django.urls import path
from .views import (
DocumentVerifyView,
SellerPanGstVerifyView,
SellerBankVerifyView,
VendorListView,
TransactionDetailView,
TransactionListView,
@ -13,6 +14,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("verify/seller/bank/", SellerBankVerifyView.as_view(), name="verify_seller_bank"),
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

@ -9,7 +9,7 @@ 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, PANDetails
from .models import VerificationTransaction, VendorAttemptLog, PANDetails, SellerBankVerification
from .serializers import (
VerifyDocumentRequestSerializer,
VerifySellerPanGstRequestSerializer,
@ -179,10 +179,178 @@ class DocumentVerifyView(APIView):
except Exception as pan_err:
logger.error("Failed to store PAN record in pan_details table: %s", pan_err)
# If bank verification was performed and seller_id is supplied, persist directly to seller_bank_verification table
seller_id = input_data.get("seller_id") or request.data.get("seller_id")
if doc_type in ["bank_account", "bank_verify_pd", "bank_verify_v5", "bank_pd"] and seller_id:
try:
b_data = engine_result.data or {}
last_err_code = engine_result.attempts[-1].get("error_code") if engine_result.attempts else None
SellerBankVerification.objects.update_or_create(
seller_id=str(seller_id),
defaults={
"transaction": transaction,
"account_number": str(b_data.get("account_number") or input_data.get("account_number") or ""),
"ifsc": str(b_data.get("ifsc") or input_data.get("ifsc") or "").upper(),
"account_ifsc": b_data.get("account_ifsc") or b_data.get("ifsc") or str(input_data.get("ifsc") or "").upper(),
"beneficiary_name": b_data.get("beneficiary_name") or b_data.get("account_holder_name"),
"is_valid": bool(b_data.get("is_valid", engine_result.is_success)),
"account_exists": bool(b_data.get("account_exists", engine_result.is_success)),
"failure_reason": b_data.get("failure_reason") or engine_result.error_message,
"npci_error_code": b_data.get("npci_error_code") or last_err_code,
"tranx_id": b_data.get("tranx_id") or b_data.get("transaction_id") or str(transaction.reference_id),
"timestamp": str(b_data.get("timestamp") or ""),
"user_consent": str(b_data.get("user_consent") or input_data.get("user_consent") or "true"),
"vendor_name": engine_result.successful_vendor or "codeplus",
"status": engine_result.status.value,
"latency_ms": round(engine_result.total_latency_ms, 2),
}
)
except Exception as bank_err:
logger.error("Failed to store bank record in seller_bank_verification table: %s", bank_err)
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 SellerBankVerifyView(APIView):
"""
POST /api/verify/seller/bank/
Dedicated composite endpoint for seller bank account verification.
Verifies bank account via Codeplus Penny Drop v5 and automatically
saves the complete record to seller_bank_verification table with seller_id as primary key:
- seller_id (primarykey)
- account_number
- ifsc
- account_ifsc
- beneficiary_name
- is_valid
- account_exists
- failure_reason
- npci_error_code
- tranx_id
- timestamp
- user_consent
- vendor_name
- status
- latency_ms
"""
def post(self, request, *args, **kwargs):
seller_id = str(request.data.get("seller_id") or "").strip()
account_number = str(request.data.get("account_number") or "").strip()
ifsc = str(request.data.get("ifsc") or "").strip().upper()
user_consent = str(request.data.get("user_consent") or "true")
preferred_vendor = request.data.get("preferred_vendor")
if not seller_id:
return Response({"is_success": False, "error": "seller_id is required"}, status=status.HTTP_400_BAD_REQUEST)
if not account_number:
return Response({"is_success": False, "error": "account_number is required"}, status=status.HTTP_400_BAD_REQUEST)
if not ifsc:
return Response({"is_success": False, "error": "ifsc is required"}, status=status.HTTP_400_BAD_REQUEST)
# Run verification via waterfall engine
engine_result = engine.verify(
document_type="bank_verify_pd",
data={
"seller_id": seller_id,
"account_number": account_number,
"ifsc": ifsc,
"user_consent": user_consent,
},
preferred_vendor=preferred_vendor,
)
safe_input = mask_sensitive_payload({"seller_id": seller_id, "account_number": account_number, "ifsc": ifsc})
transaction = VerificationTransaction.objects.create(
document_type="BANK_ACCOUNT",
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,
)
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),
)
b_data = engine_result.data or {}
is_valid = bool(b_data.get("is_valid", engine_result.is_success))
account_exists = bool(b_data.get("account_exists", engine_result.is_success))
beneficiary_name = b_data.get("beneficiary_name") or b_data.get("account_holder_name") or ""
account_ifsc = b_data.get("account_ifsc") or ifsc
last_err_code = engine_result.attempts[-1].get("error_code") if engine_result.attempts else None
failure_reason = b_data.get("failure_reason") or engine_result.error_message
npci_error_code = b_data.get("npci_error_code") or last_err_code
tranx_id = b_data.get("tranx_id") or b_data.get("transaction_id") or str(transaction.reference_id)
timestamp = str(b_data.get("timestamp") or "")
vendor_name = engine_result.successful_vendor or "codeplus"
res_status = engine_result.status.value
latency_ms = round(engine_result.total_latency_ms, 2)
# Save to SellerBankVerification table with seller_id as primary key
SellerBankVerification.objects.update_or_create(
seller_id=str(seller_id),
defaults={
"transaction": transaction,
"account_number": account_number,
"ifsc": ifsc,
"account_ifsc": account_ifsc,
"beneficiary_name": beneficiary_name,
"is_valid": is_valid,
"account_exists": account_exists,
"failure_reason": failure_reason,
"npci_error_code": npci_error_code,
"tranx_id": tranx_id,
"timestamp": timestamp,
"user_consent": user_consent,
"vendor_name": vendor_name,
"status": res_status,
"latency_ms": latency_ms,
}
)
response_payload = {
"seller_id": seller_id,
"reference_id": str(transaction.reference_id),
"is_success": is_valid,
"status": res_status,
"account_number": account_number,
"ifsc": ifsc,
"account_ifsc": account_ifsc,
"beneficiary_name": beneficiary_name,
"is_valid": is_valid,
"account_exists": account_exists,
"failure_reason": failure_reason,
"npci_error_code": npci_error_code,
"tranx_id": tranx_id,
"timestamp": timestamp,
"user_consent": user_consent,
"vendor_name": vendor_name,
"latency_ms": latency_ms,
"attempts_count": len(engine_result.attempts),
"attempts": engine_result.attempts,
}
transaction.response_payload = response_payload
transaction.save(update_fields=["response_payload"])
http_code = status.HTTP_200_OK if is_valid else status.HTTP_422_UNPROCESSABLE_ENTITY
return Response(response_payload, status=http_code)
class SellerPanGstVerifyView(APIView):
"""
POST /api/verify/seller/