From 418c7e681df23a9ed65a1ca76a399118839be8dd Mon Sep 17 00:00:00 2001 From: vickytechkey Date: Sat, 17 Oct 2026 13:50:16 +0530 Subject: [PATCH] feat: add verification models, admin configuration, views, tests, and database migrations --- verifier/admin.py | 23 ++- .../migrations/0004_sellerbankverification.py | 42 +++++ verifier/models.py | 41 +++++ verifier/tests.py | 66 +++++++ verifier/urls.py | 2 + verifier/views.py | 170 +++++++++++++++++- 6 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 verifier/migrations/0004_sellerbankverification.py diff --git a/verifier/admin.py b/verifier/admin.py index 002493d..930a087 100644 --- a/verifier/admin.py +++ b/verifier/admin.py @@ -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") + diff --git a/verifier/migrations/0004_sellerbankverification.py b/verifier/migrations/0004_sellerbankverification.py new file mode 100644 index 0000000..0ea94da --- /dev/null +++ b/verifier/migrations/0004_sellerbankverification.py @@ -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', + }, + ), + ] diff --git a/verifier/models.py b/verifier/models.py index c3719aa..bdc2f2e 100644 --- a/verifier/models.py +++ b/verifier/models.py @@ -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'})" + + diff --git a/verifier/tests.py b/verifier/tests.py index 549e662..30b45d7 100644 --- a/verifier/tests.py +++ b/verifier/tests.py @@ -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) + + diff --git a/verifier/urls.py b/verifier/urls.py index 36cbf04..ca83405 100644 --- a/verifier/urls.py +++ b/verifier/urls.py @@ -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//", TransactionDetailView.as_view(), name="transaction_detail"), diff --git a/verifier/views.py b/verifier/views.py index e2ae2c7..12480cb 100644 --- a/verifier/views.py +++ b/verifier/views.py @@ -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/