#!/usr/bin/env python3 """ Data mapper for field transformations. Maps ACH parser output to database format. """ from datetime import datetime from decimal import Decimal from typing import Dict, Any from logging_config import get_logger from db.models import TransactionRecord logger = get_logger(__name__) class DataMapper: """Maps parsed ACH transactions to database records.""" @staticmethod def convert_date(date_str: str) -> str: """ Convert ACH date string to DDMMYYYY format. ACH format: DD/MM/YY (e.g., '19/01/26') Output format: DDMMYYYY (e.g., '19012026') Args: date_str: Date string in DD/MM/YY format Returns: Date string in DDMMYYYY format """ try: if not date_str or len(date_str) < 8: raise ValueError(f"Invalid date format: {date_str}") # Parse DD/MM/YY parsed_date = datetime.strptime(date_str, '%d/%m/%y') # Return in DDMMYYYY format return parsed_date.strftime('%d%m%Y') except Exception as e: logger.error(f"Error converting date '{date_str}': {e}") # Return today's date in DDMMYYYY format as fallback return datetime.now().strftime('%d%m%Y') @staticmethod def calculate_txnind(amount_str: str) -> str: """ Calculate transaction indicator from amount. Args: amount_str: Amount as string Returns: 'CR' for credit (>= 0), 'DR' for debit (< 0) """ try: amount = Decimal(amount_str.strip()) return 'DR' if amount < 0 else 'CR' except Exception as e: logger.error(f"Error calculating TXNIND for amount '{amount_str}': {e}") return 'CR' # Default to credit @staticmethod def process_status(status: str) -> str: """ Process status field. If status contains 'processed' (case-insensitive), return 'Processed'. Otherwise return the original status. Args: status: Original status text Returns: 'Processed' if status contains 'processed', else original status """ try: if not status: return '' # Check if 'processed' is in status (case-insensitive) if 'processed' in status.lower(): return 'Processed' # Otherwise return original status return status except Exception as e: logger.error(f"Error processing status: {e}") return status @staticmethod def pad_account_number(account_number: str) -> str: """ Pad account number with leading zeroes to make it 17 digits. Args: account_number: Account number string Returns: Account number padded to 17 digits with leading zeroes """ try: if not account_number: return '0' * 17 # Remove any existing spaces and pad to 17 digits clean_account = account_number.strip() return clean_account.zfill(17) except Exception as e: logger.error(f"Error padding account number '{account_number}': {e}") return '0' * 17 @staticmethod def convert_amount(amount_str: str) -> Decimal: """ Convert amount string to Decimal. Args: amount_str: Amount as string Returns: Decimal representation of amount """ try: if not amount_str: return Decimal('0') amount = Decimal(amount_str.strip()) return abs(amount) # Store absolute value, use TXNIND for sign except Exception as e: logger.error(f"Error converting amount '{amount_str}': {e}") return Decimal('0') @classmethod def map_transaction( cls, parsed_transaction: Dict[str, Any], bankcode: str ) -> TransactionRecord: """ Map parsed transaction to database record. Args: parsed_transaction: Transaction from ACHParser bankcode: Bank code for this transaction Returns: TransactionRecord ready for database insertion """ try: amount_str = parsed_transaction.get('amount', '0') amount = cls.convert_amount(amount_str) txnind = cls.calculate_txnind(amount_str) tran_date = cls.convert_date(parsed_transaction.get('date', '')) # Pad account number to 17 digits padded_account = cls.pad_account_number(parsed_transaction.get('cust_acct', '')) # Process status (check for 'processed' keyword) status = cls.process_status(parsed_transaction.get('sys', '')) record = TransactionRecord( narration=parsed_transaction.get('remarks', '')[:500], # Keep original remarks status=status, # 'Processed' if status contains 'processed', else original bankcode=bankcode, jrnl_id=parsed_transaction.get('jrnl_no', ''), tran_date=tran_date, cbs_acct=padded_account, # Padded to 17 digits tran_amt=amount, txnind=txnind, ) return record except Exception as e: logger.error(f"Error mapping transaction: {e}", exc_info=True) raise @classmethod def map_transactions( cls, parsed_transactions: list, bankcode: str ) -> list: """ Map multiple transactions. Args: parsed_transactions: List of transactions from ACHParser bankcode: Bank code for these transactions Returns: List of TransactionRecord objects """ records = [] for txn in parsed_transactions: try: record = cls.map_transaction(txn, bankcode) records.append(record) except Exception as e: logger.warning(f"Skipping transaction due to error: {e}") continue logger.info(f"Mapped {len(records)} transactions for bank {bankcode}") return records