Initial commit

This commit is contained in:
2026-03-12 12:46:33 +05:30
commit a2be502225
18 changed files with 1841 additions and 0 deletions

6
db/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""Database module for ACH file processing."""
from .oracle_connector import OracleConnector
from .repository import Repository
__all__ = ['OracleConnector', 'Repository']

77
db/models.py Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
Data models for ACH file processing.
Represents database records and transactions.
"""
from dataclasses import dataclass, asdict
from datetime import date, datetime
from decimal import Decimal
from typing import Optional
@dataclass
class NEFTOutwardRecord:
"""Represents a parsed NEFT Inward transaction mapped to DB columns."""
txnind: str # VARCHAR2(2), default "DR"
jrnl_id: str # VARCHAR2(4000), NOT NULL
ref_no: str # VARCHAR2(400), NOT NULL
txn_date: str # VARCHAR2(100), NOT NULL
txn_amt: Optional[Decimal] # NUMBER(17,2)
sender_ifsc: str # VARCHAR2(400)
reciever_ifsc: str # VARCHAR2(400)
sender_acct_no: str # VARCHAR2(400)
sender_acct_name: str # VARCHAR2(400)
recvr_acct_no: str # VARCHAR2(400)
recvr_acct_name: str # VARCHAR2(400)
reject_code: str # VARCHAR2(400)
reject_reason: str # VARCHAR2(400)
benef_address: str # VARCHAR2(400)
msg_type: str # VARCHAR2(400)
bank_code: str
def to_dict(self):
"""Convert to dictionary for DB insertion."""
return {
"TXNIND": self.txnind,
"BANKCODE": self.bank_code,
"JRNL_ID": self.jrnl_id,
"REF_NO": self.ref_no,
"TRAN_DATE": self.txn_date,
"TXN_AMT": self.txn_amt,
"SENDER_IFSC": self.sender_ifsc,
"RECIEVER_IFSC": self.reciever_ifsc,
"SENDER_ACCT_NO": self.sender_acct_no,
"SENDER_NAME": self.sender_acct_name,
"RECVR_ACCT_NO": self.recvr_acct_no,
"RECIEVER_NAME": self.recvr_acct_name,
"REJECT_CODE": self.reject_code,
"REJECT_REASON": self.reject_reason,
"BENEFICIARY_ADDRESS": self.benef_address,
"MSG_TYPE": self.msg_type,
}
@dataclass
class ProcessedFile:
"""Represents a processed file record for ach_processed_files table."""
filename: str
bankcode: str
file_path: str
transaction_count: int
status: str = 'SUCCESS'
error_message: Optional[str] = None
processed_at: Optional[datetime] = None
def to_dict(self):
"""Convert to dictionary for database insertion."""
return {
'filename': self.filename,
'bankcode': self.bankcode,
'file_path': self.file_path,
'transaction_count': self.transaction_count,
'status': self.status,
'error_message': self.error_message,
'processed_at': self.processed_at or datetime.now(),
}

111
db/oracle_connector.py Normal file
View File

@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""
Oracle database connection pool manager using oracledb.
Manages connections with pooling and health checks.
oracledb is the modern, simpler replacement for cx_Oracle.
No Oracle Instant Client required - uses Thick or Thin mode.
"""
import oracledb
from logging_config import get_logger
from config import get_config
logger = get_logger(__name__)
class OracleConnector:
"""Manages Oracle database connections with pooling."""
def __init__(self):
"""Initialize connection pool."""
self.pool = None
self.config = get_config()
def initialize_pool(self):
"""Create connection pool."""
try:
# Build connection string for oracledb
# Format: user/password@host:port/service_name
connection_string = (
f"{self.config.db_user}/{self.config.db_password}@"
f"{self.config.db_host}:{self.config.db_port}/{self.config.db_service_name}"
)
# Create connection pool using oracledb API
# Note: oracledb uses 'min' and 'max' for pool sizing
self.pool = oracledb.create_pool(
dsn=connection_string,
min=self.config.db_pool_min,
max=self.config.db_pool_max,
increment=1,
)
logger.debug(f"Oracle connection pool initialized: min={self.config.db_pool_min}, max={self.config.db_pool_max}")
return True
except oracledb.DatabaseError as e:
logger.error(f"Failed to initialize connection pool: {e}", exc_info=True)
return False
except Exception as e:
logger.error(f"Unexpected error initializing pool: {e}", exc_info=True)
return False
def get_connection(self):
"""Get connection from pool."""
if not self.pool:
self.initialize_pool()
try:
conn = self.pool.acquire()
logger.debug("Connection acquired from pool")
return conn
except oracledb.DatabaseError as e:
logger.error(f"Failed to acquire connection: {e}", exc_info=True)
raise
except Exception as e:
logger.error(f"Unexpected error acquiring connection: {e}", exc_info=True)
raise
def close_pool(self):
"""Close connection pool."""
if self.pool:
try:
self.pool.close()
logger.info("Connection pool closed")
except Exception as e:
logger.error(f"Error closing pool: {e}")
def test_connection(self):
"""Test database connectivity."""
try:
conn = self.get_connection()
cursor = conn.cursor()
cursor.execute("SELECT 1 FROM dual")
result = cursor.fetchone()
cursor.close()
conn.close()
logger.debug("Database connection test successful")
return True
except Exception as e:
logger.error(f"Database connection test failed: {e}")
return False
def __enter__(self):
"""Context manager entry."""
return self
def __exit__(self, exc_type, exc_val, exc_tb):
"""Context manager exit."""
self.close_pool()
# Global connector instance
_connector = None
def get_connector():
"""Get or create global connector instance."""
global _connector
if _connector is None:
_connector = OracleConnector()
return _connector

307
db/repository.py Normal file
View File

@@ -0,0 +1,307 @@
#!/usr/bin/env python3
"""
Data access layer for NEFT inward file processing.
Handles CRUD operations and transaction management.
"""
from typing import List, Optional
from logging_config import get_logger
from .oracle_connector import get_connector
from .models import NEFTInwardRecord, ProcessedFile
logger = get_logger(__name__)
class Repository:
"""Data access layer for NEFT inward processing."""
def __init__(self):
"""Initialize repository with connector."""
self.connector = get_connector()
# ---------------------------------------------------------
# ADDED: Account validation using last 12 digits of RECVR_ACCT_NO
# ---------------------------------------------------------
def validate_account_exists(self, account_number: str) -> bool:
"""
Validate if account number exists in dep_account table.
Args:
account_number: Beneficiary account number (RECVR_ACCT_NO)
Returns:
True if account exists in dep_account.link_accno, False otherwise
"""
if not account_number:
return False
last12 = str(account_number)[-12:]
conn = self.connector.get_connection()
try:
cursor = conn.cursor()
cursor.execute(
"SELECT COUNT(*) FROM dep_account WHERE link_accno = :accno",
{'accno': last12}
)
count = cursor.fetchone()[0]
return count > 0
except Exception as e:
logger.warning(f"Error validating account {account_number}: {e}")
return False
finally:
cursor.close()
conn.close()
# ---------------------------------------------------------
# UPDATED: bulk_insert_transactions WITH VALIDATION
# ---------------------------------------------------------
def bulk_insert_transactions(self, transactions: List[NEFTInwardRecord]) -> tuple:
"""
Bulk insert NEFT transactions into inward_neft_api_log.
Records with invalid beneficiary account numbers are skipped.
Args:
transactions: List of NEFTOutwardRecord objects
Returns:
(inserted_count, skipped_count)
"""
if not transactions:
logger.warning("No transactions to insert")
return 0, 0
valid_transactions = []
skipped_count = 0
for txn in transactions:
acct = txn.sender_acct_no
if self.validate_account_exists(acct):
valid_transactions.append(txn)
else:
skipped_count += 1
if not valid_transactions:
logger.debug(f"All {skipped_count} transactions skipped (invalid Remitter accounts)")
return 0, skipped_count
conn = self.connector.get_connection()
cursor = None
try:
cursor = conn.cursor()
batch_data = [txn.to_dict() for txn in valid_transactions]
logger.info(batch_data)
insert_sql = """
INSERT INTO outward_neft_api_log (
TXNIND,
JRNL_ID,
BANKCODE,
REF_NO,
TRAN_DATE,
TXN_AMT,
SENDER_IFSC,
RECIEVER_IFSC,
SENDER_ACCT_NO,
SENDER_NAME,
RECVR_ACCT_NO,
RECIEVER_NAME,
REJECT_CODE,
REJECT_REASON,
BENEFICIARY_ADDRESS,
MSG_TYPE
) VALUES (
:TXNIND,
:JRNL_ID,
:BANKCODE,
:REF_NO,
:TRAN_DATE,
:TXN_AMT,
:SENDER_IFSC,
:RECIEVER_IFSC,
:SENDER_ACCT_NO,
:SENDER_NAME,
:RECVR_ACCT_NO,
:RECIEVER_NAME,
:REJECT_CODE,
:REJECT_REASON,
:BENEFICIARY_ADDRESS,
:MSG_TYPE
)
"""
cursor.executemany(insert_sql, batch_data)
conn.commit()
inserted_count = len(valid_transactions)
logger.info(f"Inserted {inserted_count} NEFT transactions into outward_neft_api_log")
return inserted_count, skipped_count
except Exception as e:
if conn:
conn.rollback()
logger.error(f"Error inserting NEFT transactions: {e}", exc_info=True)
raise
finally:
if cursor:
cursor.close()
conn.close()
# ---------------------------------------------------------
# NOTHING ELSE BELOW THIS LINE WAS TOUCHED
# ---------------------------------------------------------
def is_file_processed(self, filename: str, bankcode: str) -> bool:
conn = self.connector.get_connection()
cursor = None
try:
cursor = conn.cursor()
cursor.execute(
"""
SELECT COUNT(*)
FROM neft_processed_files
WHERE filename = :filename
AND bankcode = :bankcode
""",
{'filename': filename, 'bankcode': bankcode}
)
count = cursor.fetchone()[0]
return count > 0
except Exception as e:
logger.error(f"Error checking processed file: {e}", exc_info=True)
return False
finally:
if cursor:
cursor.close()
conn.close()
def mark_file_processed(self, processed_file: ProcessedFile) -> bool:
conn = self.connector.get_connection()
cursor = None
try:
cursor = conn.cursor()
file_data = processed_file.to_dict()
insert_sql = """
INSERT INTO neft_processed_files (
filename, bankcode, file_path, transaction_count,
status, error_message, processed_at
) VALUES (
:filename, :bankcode, :file_path, :transaction_count,
:status, :error_message, :processed_at
)
"""
cursor.execute(insert_sql, file_data)
conn.commit()
logger.info(f"Marked file as processed: {processed_file.filename}")
return True
except Exception as e:
if conn:
conn.rollback()
logger.error(f"Error marking file as processed: {e}", exc_info=True)
return False
finally:
if cursor:
cursor.close()
conn.close()
def get_processed_files(self, bankcode: Optional[str] = None) -> List[str]:
conn = self.connector.get_connection()
cursor = None
try:
cursor = conn.cursor()
if bankcode:
cursor.execute(
"""
SELECT filename
FROM neft_processed_files
WHERE bankcode = :bankcode
ORDER BY processed_at DESC
""",
{'bankcode': bankcode}
)
else:
cursor.execute(
"""
SELECT filename
FROM neft_processed_files
ORDER BY processed_at DESC
"""
)
filenames = [row[0] for row in cursor.fetchall()]
return filenames
except Exception as e:
logger.error(f"Error retrieving processed files: {e}", exc_info=True)
return []
finally:
if cursor:
cursor.close()
conn.close()
def call_neft_api_txn_post(self) -> bool:
conn = self.connector.get_connection()
cursor = None
try:
cursor = conn.cursor()
logger.info("Calling neft_api_txn_post procedure to process all inserted transactions...")
try:
cursor.callproc('neft_api_txn_post')
except Exception:
cursor.execute("BEGIN neft_api_txn_post; END;")
conn.commit()
logger.info("neft_api_txn_post procedure executed successfully")
return True
except Exception as e:
logger.error(f"Error calling neft_api_txn_post procedure: {e}", exc_info=True)
return False
finally:
if cursor:
cursor.close()
conn.close()
def verify_tables_exist(self):
conn = self.connector.get_connection()
cursor = None
try:
cursor = conn.cursor()
try:
cursor.execute("SELECT COUNT(*) FROM inward_neft_api_log WHERE ROWNUM = 1")
logger.info("✓ inward_neft_api_log table exists")
except Exception as e:
logger.error(f"✗ inward_neft_api_log table not found: {e}")
raise SystemExit(
"FATAL: inward_neft_api_log table must be created manually before running this application"
)
try:
cursor.execute("SELECT COUNT(*) FROM neft_processed_files WHERE ROWNUM = 1")
logger.info("✓ neft_processed_files table exists")
except Exception as e:
logger.error(f"✗ neft_processed_files table not found: {e}")
raise SystemExit(
"FATAL: neft_processed_files table must be created manually before running this application"
)
logger.info("Database tables verified successfully")
except SystemExit:
raise
except Exception as e:
logger.error(f"Error verifying tables: {e}", exc_info=True)
raise SystemExit(f"FATAL: Error verifying database tables: {e}")
finally:
if cursor:
cursor.close()
conn.close()