feat: add core verifier engine, serializers, tests, vendor client, and test portal template
All checks were successful
Deploy Beta (NATIVE) / deploy (push) Successful in 39s

This commit is contained in:
vickytechkey 2026-10-17 13:34:43 +05:30
parent 4e1ea0fcf2
commit 0291bd6195
6 changed files with 510 additions and 13 deletions

View file

@ -61,7 +61,8 @@ Environment variables configured in `docverify/settings.py` / `.env`:
| **Pan Verification (v5 Recommended)** | `POST /v5/pan-verify` | `pan`, `pan_v5`, `pan_verify` | Full PAN holder details with name, DOB, gender, masked Aadhaar & address. |
| **Detailed PAN API** | `POST /v4/pan-detailed` | `pan_detailed` | Comprehensive PAN data including address, contact info, and profile. |
| **PAN Advanced API** | `POST /v4/pan-advanced` | `pan_advanced` | Standard government database verification for PAN validity and taxpayer status. |
| **Bank Verification (Full Details)** | `POST /v4/bank-verify` | `bank_account` | Penny drop routing for account holder name, MICR, IFSC, and branch details. |
| **Bank Verify (Penny Drop - v5)** | `POST /v5/bank-verify-pd` | `bank_verify_pd`, `bank_verify_v5`, `bank_account` | Verifies bank account using account number and IFSC via Penny Drop; returns validity and account holder name. |
| **Bank Verification (Full Details - v4)** | `POST /v4/bank-verify` | `bank_account` (with `mode: 'v4'`) | Penny drop routing for account holder name, MICR, IFSC, and branch details. |
| **Fetch GST By PAN** | `POST /v4/fetch-gst` | `gst_by_pan`, `fetch_gst` | Retrieves all registered GSTIN entities associated with a business PAN. |
| **Fetch MSME By PAN** | `POST /v4/msme-pan` | `msme`, `msme_by_pan`, `udyam` | Retrieves Udyam registration number and enterprise details linked to a PAN. |
| **GST Verification (v5)** | `POST /v5/gst-verify` | `gstin`, `gst`, `gst_verify` | Verifies single GSTIN and returns legal business name and active status. |
@ -258,10 +259,106 @@ curl --location 'https://apihub.services/v4/pan-advanced' \
---
### Function 4: Bank Verification (Full Details)
### Function 4: Bank Verify (Penny Drop - v5 Recommended)
The Bank Account Verification – PD API verifies a bank account using the account number and IFSC code via automated penny drop routing. It checks whether the account is valid and active, and returns the account holder name and verified bank account details.
- **Engine Request:**
```bash
curl -X POST http://localhost:8092/api/verify/ \
-H "Content-Type: application/json" \
-d '{
"document_type": "bank_verify_pd",
"data": {
"account_number": "123456787270",
"ifsc": "UTIB0000373"
}
}'
```
*(Note: You can also use `document_type: "bank_account"`, which automatically defaults to v5 Penny Drop with fallback to v4).*
- **Direct Upstream cURL:**
```bash
curl --location 'https://apihub.services/v5/bank-verify-pd' \
--header 'X-Client-Api-ID: CAPI-YYR0YJIE0VMTDENY' \
--header 'X-Client-Api-Secret: your_client_api_secret' \
--header 'Content-Type: application/json' \
--data '{
"account_number": "123456787270",
"ifsc": "UTIB0000373"
}'
```
- **Client Input Parameters:**
| Parameter | Type | Description |
| :--- | :--- | :--- |
| `account_number` | String | Bank account number to be verified. |
| `ifsc` | String | 11-character IFSC code of the bank branch. |
- **Upstream Success Response (HTTP 200):**
```json
{
"status": "success",
"result": {
"failure_reason": null,
"account_number": "********7270",
"user": null,
"is_valid": true,
"account_holder_name": "MANOJ PRASAD",
"account_ifsc": "UTIB0000373",
"npci_error_code": null
},
"tranx_id": "9eeb9d58-04f8-4f8b-9608-99731850993f",
"timestamp": "2026-05-22T05:10:43.561554+00:00",
"chargeble": "true",
"user_consent": "true"
}
```
- **Engine Unified Response (HTTP 200):**
```json
{
"reference_id": "9eeb9d58-04f8-4f8b-9608-99731850993f",
"is_success": true,
"status": "SUCCESS",
"document_type": "bank_verify_pd",
"successful_vendor": "codeplus",
"data": {
"document_type": "BANK_ACCOUNT",
"verification_mode": "penny_drop_v5",
"account_number": "123456787270",
"masked_account_number": "********7270",
"ifsc": "UTIB0000373",
"account_ifsc": "UTIB0000373",
"beneficiary_name": "MANOJ PRASAD",
"account_holder_name": "MANOJ PRASAD",
"is_valid": true,
"account_exists": true,
"failure_reason": null,
"npci_error_code": null,
"tranx_id": "9eeb9d58-04f8-4f8b-9608-99731850993f",
"timestamp": "2026-05-22T05:10:43.561554+00:00",
"chargeble": "true",
"user_consent": "true"
},
"attempts_count": 1,
"attempts": [
{
"attempt_number": 1,
"vendor_name": "codeplus",
"is_success": true,
"status": "SUCCESS",
"latency_ms": 380.2
}
]
}
```
---
### Function 4B: Bank Verification (Full Details - v4 Legacy)
```bash
curl -X POST http://localhost:8092/api/verify/ \
-H "Content-Type: application/json" \
-d '{

View file

@ -768,11 +768,11 @@
</div>
</div>
<div class="service-tab" data-doctype="bank_account" onclick="selectService('bank_account')">
<div class="service-tab" data-doctype="bank_verify_pd" onclick="selectService('bank_verify_pd')">
<div class="service-icon">🏦</div>
<div>
<div class="service-name">Bank Account (Penny Drop)</div>
<span class="service-tag">POST /v4/bank-verify</span>
<div class="service-name">Bank Verify (Penny Drop - v5)</div>
<span class="service-tag">POST /v5/bank-verify-pd</span>
</div>
</div>
@ -990,15 +990,27 @@
{ label: "Sample Valid PAN", data: { pan: "ABCDE1234F" } }
]
},
bank_account: {
title: "Bank Account Verification (Full Details)",
docType: "BANK",
bank_verify_pd: {
title: "Bank Account Verification – PD (Penny Drop - v5)",
docType: "BANK_ACCOUNT",
fields: [
{ id: "account_number", label: "Bank Account Number", placeholder: "e.g. 10001234567890", type: "text", required: true, isCode: true },
{ id: "ifsc", label: "IFSC Code", placeholder: "e.g. SBIN0001234", type: "text", required: true, isCode: true, maxLength: 11 }
{ id: "account_number", label: "Bank Account Number", placeholder: "e.g. 123456787270", type: "text", required: true, isCode: true },
{ id: "ifsc", label: "IFSC Code", placeholder: "e.g. UTIB0000373", type: "text", required: true, isCode: true, maxLength: 11 }
],
quickFill: [
{ label: "Penny Drop Sample", data: { account_number: "123456789012", ifsc: "SBIN0000001" } },
{ label: "Penny Drop v5 Sample (UTIB0000373)", data: { account_number: "123456787270", ifsc: "UTIB0000373" } },
{ label: "HDFC Bank Sample", data: { account_number: "50100234567890", ifsc: "HDFC0000001" } }
]
},
bank_account: {
title: "Bank Account Verification (Full Details / Auto Waterfall)",
docType: "BANK_ACCOUNT",
fields: [
{ id: "account_number", label: "Bank Account Number", placeholder: "e.g. 123456787270", type: "text", required: true, isCode: true },
{ id: "ifsc", label: "IFSC Code", placeholder: "e.g. UTIB0000373", type: "text", required: true, isCode: true, maxLength: 11 }
],
quickFill: [
{ label: "Penny Drop v5 Sample", data: { account_number: "123456787270", ifsc: "UTIB0000373" } },
{ label: "HDFC Bank Sample", data: { account_number: "50100234567890", ifsc: "HDFC0000001" } }
]
},

View file

@ -15,6 +15,9 @@ class DocumentType(str, Enum):
VOTER_ID = "voter_id"
PASSPORT = "passport"
BANK_ACCOUNT = "bank_account"
BANK_VERIFY_PD = "bank_verify_pd"
BANK_VERIFY_V5 = "bank_verify_v5"
BANK_PD = "bank_pd"
GSTIN = "gstin"
GST = "gst"
GST_VERIFY = "gst_verify"

View file

@ -6,7 +6,7 @@ class VerifyDocumentRequestSerializer(serializers.Serializer):
"""Input payload serializer for document verification API."""
document_type = serializers.CharField(
required=True,
help_text="Type of document, e.g. 'pan', 'aadhaar', 'driving_license', 'voter_id', 'bank_account', 'gstin'",
help_text="Type of document, e.g. 'pan', 'aadhaar', 'driving_license', 'voter_id', 'bank_account', 'bank_verify_pd', 'gstin'",
)
data = serializers.DictField(
required=True,

View file

@ -381,6 +381,136 @@ class CodeplusVendorTests(TestCase):
self.assertFalse(result_missing_ifsc.is_success)
self.assertEqual(result_missing_ifsc.error_code, "MISSING_IFSC")
def test_codeplus_bank_verify_pd_v5_success(self):
"""Test successful Bank Verify (Penny Drop - v5) API."""
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 MockResponse:
status_code = 200
def json(self):
return mock_v5_response
from unittest.mock import patch
with patch.object(self.codeplus.session, "post", return_value=MockResponse()) as mock_post:
result = self.codeplus.verify(
"bank_verify_pd",
{"account_number": "123456787270", "ifsc": "UTIB0000373"},
)
self.assertTrue(result.is_success)
self.assertEqual(result.status, VerificationStatus.SUCCESS)
self.assertEqual(result.data["beneficiary_name"], "MANOJ PRASAD")
self.assertEqual(result.data["account_holder_name"], "MANOJ PRASAD")
self.assertEqual(result.data["account_ifsc"], "UTIB0000373")
self.assertEqual(result.data["masked_account_number"], "********7270")
self.assertEqual(result.data["tranx_id"], "9eeb9d58-04f8-4f8b-9608-99731850993f")
self.assertEqual(result.data["verification_mode"], "penny_drop_v5")
self.assertTrue(result.data["is_valid"])
mock_post.assert_called_once()
call_url = mock_post.call_args[0][0]
self.assertTrue(call_url.endswith("/v5/bank-verify-pd"))
def test_codeplus_bank_verify_pd_v5_invalid_account(self):
"""Test Bank Verify (Penny Drop - v5) when bank rejects account validity."""
mock_v5_failure = {
"status": "failed",
"result": {
"failure_reason": "Account Does Not Exist",
"account_number": "********9999",
"user": None,
"is_valid": False,
"account_holder_name": None,
"account_ifsc": "UTIB0000373",
"npci_error_code": "NPCI_ACC_INVALID",
},
"tranx_id": "8eeb9d58-04f8-4f8b-9608-99731850991a",
"timestamp": "2026-05-22T05:10:43.561554+00:00",
"chargeble": "false",
"user_consent": "true",
}
class MockResponse:
status_code = 200
def json(self):
return mock_v5_failure
from unittest.mock import patch
with patch.object(self.codeplus.session, "post", return_value=MockResponse()):
result = self.codeplus.verify(
"bank_verify_pd",
{"account_number": "999999999999", "ifsc": "UTIB0000373"},
)
self.assertFalse(result.is_success)
self.assertEqual(result.status, VerificationStatus.FAILED)
self.assertEqual(result.error_code, "NPCI_ACC_INVALID")
self.assertEqual(result.error_message, "Account Does Not Exist")
self.assertFalse(result.data["is_valid"])
def test_codeplus_bank_verify_pd_v5_http_400(self):
"""Test Bank Verify (Penny Drop - v5) HTTP 400 Bad Request error."""
class MockResponse:
status_code = 400
def json(self):
return {"message": "Invalid IFSC code format", "status": "error"}
from unittest.mock import patch
with patch.object(self.codeplus.session, "post", return_value=MockResponse()):
result = self.codeplus.verify(
"bank_verify_pd",
{"account_number": "1234567890", "ifsc": "INVALID_IFSC"},
)
self.assertFalse(result.is_success)
self.assertEqual(result.status, VerificationStatus.FAILED)
self.assertEqual(result.error_code, "HTTP_400")
self.assertIn("Invalid IFSC", result.error_message)
def test_codeplus_bank_verify_pd_v5_doctype_aliases(self):
"""Test aliases: bank_verify_v5 and bank_pd route to v5 Penny Drop."""
mock_v5_response = {
"status": "success",
"result": {
"failure_reason": None,
"account_number": "********7270",
"is_valid": True,
"account_holder_name": "MANOJ PRASAD",
"account_ifsc": "UTIB0000373",
"npci_error_code": None,
},
"tranx_id": "9eeb9d58-04f8-4f8b-9608-99731850993f",
}
class MockResponse:
status_code = 200
def json(self):
return mock_v5_response
from unittest.mock import patch
with patch.object(self.codeplus.session, "post", return_value=MockResponse()):
res1 = self.codeplus.verify("bank_verify_v5", {"account_number": "123456787270", "ifsc": "UTIB0000373"})
self.assertTrue(res1.is_success)
self.assertEqual(res1.data["account_holder_name"], "MANOJ PRASAD")
res2 = self.codeplus.verify("bank_pd", {"account_number": "123456787270", "ifsc": "UTIB0000373"})
self.assertTrue(res2.is_success)
self.assertEqual(res2.data["account_holder_name"], "MANOJ PRASAD")
def test_codeplus_fetch_gst_success(self):
"""Test successful fetch GST by PAN."""
mock_response_payload = {

View file

@ -36,6 +36,9 @@ class CodeplusVendor(BaseVendor):
DocumentType.VOTER_ID.value,
DocumentType.PASSPORT.value,
DocumentType.BANK_ACCOUNT.value,
DocumentType.BANK_VERIFY_PD.value,
DocumentType.BANK_VERIFY_V5.value,
DocumentType.BANK_PD.value,
DocumentType.GSTIN.value,
DocumentType.GST.value,
DocumentType.GST_VERIFY.value,
@ -113,6 +116,9 @@ class CodeplusVendor(BaseVendor):
DocumentType.VOTER_ID.value: self.verify_voter_id,
DocumentType.PASSPORT.value: self.verify_passport,
DocumentType.BANK_ACCOUNT.value: self.verify_bank_account,
DocumentType.BANK_VERIFY_PD.value: self.verify_bank_verify_pd_v5,
DocumentType.BANK_VERIFY_V5.value: self.verify_bank_verify_pd_v5,
DocumentType.BANK_PD.value: self.verify_bank_verify_pd_v5,
DocumentType.GSTIN.value: self.verify_gstin,
DocumentType.GST.value: self.verify_gstin,
DocumentType.GST_VERIFY.value: self.verify_gstin,
@ -572,7 +578,256 @@ class CodeplusVendor(BaseVendor):
def verify_bank_account(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult:
"""
Bank Verification (Full Details) API:
Bank Account Verification Dispatcher:
- Defaults to Bank Verify (Penny Drop - v5) Recommended API (`verify_bank_verify_pd_v5`)
- If mode or version is 'v4', 'detailed', or 'full', routes to `verify_bank_account_v4`
- If v5 encounters an upstream retryable failure (5xx or timeout), falls back to v4
"""
mode = str(data.get("mode", "")).lower()
version = str(data.get("version", "")).lower()
if version == "v4" or mode in ("v4", "detailed", "full"):
return self.verify_bank_account_v4(data, files)
# Primary execution: Bank Verify (Penny Drop - v5)
v5_result = self.verify_bank_verify_pd_v5(data, files)
if v5_result.is_success:
return v5_result
# Fallback locally to v4 if upstream server error / timeout or if upstream returns v4 response structure
is_v4_payload = isinstance(v5_result.raw_response, dict) and "bank_data" in v5_result.raw_response.get("data", {})
if v5_result.retryable or is_v4_payload:
logger.info("Codeplus Bank Verify fallback to v4 triggered (retryable=%s, is_v4=%s)...", v5_result.retryable, is_v4_payload)
v4_result = self.verify_bank_account_v4(data, files)
if v4_result.is_success:
return v4_result
return v5_result
def verify_bank_verify_pd_v5(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult:
"""
Bank Verify (Penny Drop - v5):
Endpoint: POST https://apihub.services/v5/bank-verify-pd
Payload:
{
"account_number": "<ACCOUNT_NUMBER>",
"ifsc": "<IFSC_CODE>"
}
Headers:
X-Client-Api-ID: <id>
X-Client-Api-Secret: <secret>
Content-Type: application/json
Description:
Verifies a bank account using account number and IFSC code via penny drop.
Returns validity, account holder name, and verified bank account details.
Response (HTTP 200):
{
"status": "success",
"result": {
"failure_reason": null,
"account_number": "********7270",
"user": null,
"is_valid": true,
"account_holder_name": "MANOJ PRASAD",
"account_ifsc": "UTIB0000373",
"npci_error_code": null
},
"tranx_id": "9eeb9d58-04f8-4f8b-9608-99731850993f",
"timestamp": "2026-05-22T05:10:43.561554+00:00",
"chargeble": "true",
"user_consent": "true"
}
"""
endpoint = f"{self.base_url}/v5/bank-verify-pd"
account_number = str(
data.get("account_number")
or data.get("bank_account")
or data.get("account_no")
or data.get("account")
or ""
).strip()
ifsc = str(
data.get("ifsc")
or data.get("ifsc_code")
or ""
).strip().upper()
if not account_number:
return VendorResult(
vendor_name=self.name,
is_success=False,
status=VerificationStatus.FAILED,
error_code="MISSING_ACCOUNT_NUMBER",
error_message="account_number is required for Bank Verify (Penny Drop - v5)",
retryable=False,
)
if not ifsc:
return VendorResult(
vendor_name=self.name,
is_success=False,
status=VerificationStatus.FAILED,
error_code="MISSING_IFSC",
error_message="ifsc code is required for Bank Verify (Penny Drop - v5)",
retryable=False,
)
payload = {
"account_number": account_number,
"ifsc": ifsc,
}
headers = self.get_headers()
try:
response = self.session.post(
endpoint,
json=payload,
headers=headers,
timeout=self.timeout,
)
try:
raw_json = response.json()
except Exception:
raw_json = {"text": response.text, "status_code": response.status_code}
api_status = str(raw_json.get("status", "")).lower()
result_block = raw_json.get("result") if isinstance(raw_json.get("result"), dict) else {}
is_valid = result_block.get("is_valid")
account_holder_name = result_block.get("account_holder_name") or ""
failure_reason = result_block.get("failure_reason")
npci_error_code = result_block.get("npci_error_code")
masked_acc = result_block.get("account_number") or (
f"********{account_number[-4:]}" if len(account_number) >= 4 else account_number
)
account_ifsc = result_block.get("account_ifsc") or ifsc
if response.status_code == 200:
# Success condition: status == 'success' and is_valid is True
if api_status == "success" and (is_valid is True or (is_valid is None and account_holder_name and not failure_reason)):
normalized_result = {
"document_type": "BANK_ACCOUNT",
"verification_mode": "penny_drop_v5",
"account_number": account_number,
"masked_account_number": masked_acc,
"ifsc": ifsc,
"account_ifsc": account_ifsc,
"beneficiary_name": account_holder_name,
"account_holder_name": account_holder_name,
"is_valid": True,
"account_exists": True,
"failure_reason": None,
"npci_error_code": None,
"user": result_block.get("user"),
"tranx_id": raw_json.get("tranx_id"),
"transaction_id": raw_json.get("tranx_id"),
"timestamp": raw_json.get("timestamp"),
"chargeble": raw_json.get("chargeble"),
"user_consent": raw_json.get("user_consent"),
"bank_details": result_block,
}
return VendorResult(
vendor_name=self.name,
is_success=True,
status=VerificationStatus.SUCCESS,
data=normalized_result,
raw_response=raw_json,
)
# HTTP 200 with verification failure or account invalid
err_code = str(npci_error_code or "INVALID_BANK_ACCOUNT")
err_message = failure_reason or raw_json.get("message") or "Bank account verification failed"
normalized_result = {
"document_type": "BANK_ACCOUNT",
"verification_mode": "penny_drop_v5",
"account_number": account_number,
"masked_account_number": masked_acc,
"ifsc": ifsc,
"account_ifsc": account_ifsc,
"beneficiary_name": account_holder_name,
"account_holder_name": account_holder_name,
"is_valid": False,
"account_exists": False,
"failure_reason": failure_reason,
"npci_error_code": npci_error_code,
"user": result_block.get("user"),
"tranx_id": raw_json.get("tranx_id"),
"transaction_id": raw_json.get("tranx_id"),
"timestamp": raw_json.get("timestamp"),
"chargeble": raw_json.get("chargeble"),
"user_consent": raw_json.get("user_consent"),
"bank_details": result_block,
}
logger.warning(
"Codeplus Bank Verify PD v5 returned failure: npci_error=%s, failure_reason=%s",
npci_error_code,
failure_reason,
)
return VendorResult(
vendor_name=self.name,
is_success=False,
status=VerificationStatus.FAILED,
data=normalized_result,
raw_response=raw_json,
error_code=err_code,
error_message=err_message,
retryable=False,
)
# Upstream HTTP error status (400, 500, etc.)
is_server_error = response.status_code >= 500
err_code = str(npci_error_code or f"HTTP_{response.status_code}")
err_message = failure_reason or raw_json.get("message") or raw_json.get("detail") or raw_json.get("error") or "Bank verification failed"
if isinstance(err_message, dict):
err_message = str(err_message)
logger.warning(
"Codeplus Bank Verify PD v5 failed: status_code=%d, code=%s, message=%s",
response.status_code,
err_code,
err_message,
)
return VendorResult(
vendor_name=self.name,
is_success=False,
status=VerificationStatus.ERROR if is_server_error else VerificationStatus.FAILED,
data=raw_json if isinstance(raw_json, dict) else {},
raw_response=raw_json,
error_code=err_code,
error_message=str(err_message),
retryable=is_server_error or response.status_code in (429, 408, 401, 403),
)
except requests.exceptions.Timeout as exc:
logger.warning("Codeplus Bank Verify PD v5 timed out: %s", exc)
return VendorResult(
vendor_name=self.name,
is_success=False,
status=VerificationStatus.ERROR,
error_code="TIMEOUT",
error_message=f"Request to Codeplus Bank Verify PD v5 timed out: {exc}",
retryable=True,
)
except requests.exceptions.RequestException as exc:
logger.warning("Codeplus Bank Verify PD v5 network exception: %s", exc)
return VendorResult(
vendor_name=self.name,
is_success=False,
status=VerificationStatus.ERROR,
error_code="NETWORK_ERROR",
error_message=f"Network error connecting to Codeplus Bank Verify PD v5 API: {exc}",
retryable=True,
)
def verify_bank_account_v4(self, data: Dict[str, Any], files: Optional[Dict[str, Any]] = None) -> VendorResult:
"""
Bank Verification (Full Details - v4) API:
Endpoint: POST https://apihub.services/v4/bank-verify
Payload:
{