feat: add verification models, admin configuration, views, tests, and database migrations
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
0291bd6195
commit
418c7e681d
6 changed files with 342 additions and 2 deletions
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
42
verifier/migrations/0004_sellerbankverification.py
Normal file
42
verifier/migrations/0004_sellerbankverification.py
Normal 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',
|
||||
},
|
||||
),
|
||||
]
|
||||
|
|
@ -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'})"
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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/
|
||||
|
|
|
|||
Loading…
Reference in a new issue