""" Payment Service - Core Authorize.net API interactions for EAMCO. This module provides the canonical implementations of payment processing functions including transaction parsing, customer profile management, and payment operations. """ import logging import traceback import re import enum from typing import Tuple, Optional from authorizenet import apicontractsv1 logger = logging.getLogger(__name__) from authorizenet.apicontrollers import ( createTransactionController, createCustomerProfileController, createCustomerPaymentProfileController, getCustomerProfileController ) from authorizenet.constants import constants as auth_net_constants from .. import schemas from ..constants import TransactionStatus, TransactionType, STATE_ID_TO_ABBREVIATION from ..utils import sanitize_input from config import load_config # Load Authorize.net credentials from config import load_config, API_LOGIN_ID, TRANSACTION_KEY, VALIDATION_MODE, ENVIRONMENT # Load Authorize.net environment variables ApplicationConfig = load_config() # Set environment auth_net_constants.environment = ENVIRONMENT def _is_e00121_response(response): """ Check if the Authorize.Net response contains E00121 error (invalid payment profile ID). """ if response is None: return False try: if hasattr(response, 'messages') and response.messages is not None: # Check for E00121 in different response message structures if hasattr(response.messages, 'message'): message = response.messages.message # Handle list of messages if isinstance(message, list): for msg in message: if getattr(msg, 'code', '') == 'E00121': logger.debug("E00121 detected in message list") return True # Handle single message elif hasattr(message, 'code'): if message.code == 'E00121': logger.debug(f"E00121 detected: '{getattr(message, 'text', 'No details')}'") return True else: logger.debug(f"Message code: '{message.code}' (not E00121)") return False except Exception as e: logger.debug(f"Error checking for E00121: {str(e)}") return False def _get_authnet_error_message(response): """ Robust error parsing function that correctly handles the API's response format. """ if response is None: return "No response from payment gateway." try: if hasattr(response, 'messages') and response.messages is not None: if hasattr(response, 'transactionResponse') and response.transactionResponse is not None and hasattr(response.transactionResponse, 'errors') and response.transactionResponse.errors: error = response.transactionResponse.errors[0] return f"Error {error.errorCode}: {error.errorText}" if hasattr(response.messages, 'message'): message_list = response.messages.message if not isinstance(message_list, list): message_list = [message_list] if message_list: msg = message_list[0] code = msg.code if hasattr(msg, 'code') else 'Unknown' text = msg.text if hasattr(msg, 'text') else 'No details provided.' return f"Error {code}: {text}" except Exception as e: logger.debug(f"Error while parsing Auth.Net error message: {e}") return "An unparsable error occurred with the payment gateway." return "An unknown error occurred with the payment gateway." def parse_authnet_response(response) -> Tuple[TransactionStatus, Optional[str], Optional[str]]: """ Parse Authorize.net response with proper attribute access for SDK objects. This is the CANONICAL implementation - all routers should import this function rather than defining their own version. Args: response: Authorize.net API response object Returns: Tuple of (status, auth_net_transaction_id, rejection_reason) - status: TransactionStatus.APPROVED (0) or TransactionStatus.DECLINED (1) - auth_net_transaction_id: Transaction ID string if successful, None otherwise - rejection_reason: Error message string if declined, None if approved """ logger.debug(f"Parsing Authorize.net response, type: {type(response)}") if response is None: logger.error("Authorize.net response is None") return TransactionStatus.DECLINED, None, "No response from payment gateway" if not hasattr(response, 'messages') or response.messages is None: logger.error("Authorize.net response missing messages attribute") return TransactionStatus.DECLINED, None, "Invalid response from payment gateway" if response.messages.resultCode == "Ok": logger.debug("Response resultCode is Ok - APPROVED path") status = TransactionStatus.APPROVED auth_net_transaction_id = None rejection_reason = None # Extract transaction ID with proper error handling try: if hasattr(response, 'transactionResponse') and response.transactionResponse is not None: if hasattr(response.transactionResponse, 'transId') and response.transactionResponse.transId: auth_net_transaction_id = str(response.transactionResponse.transId) logger.debug(f"Extracted transaction ID: {auth_net_transaction_id}") else: logger.debug("transactionResponse exists but no transId found") else: logger.debug("No transactionResponse in approved response") except Exception as e: logger.warning(f"Exception extracting transaction ID: {e}") return status, auth_net_transaction_id, rejection_reason else: logger.debug("Response resultCode is not Ok - DECLINED path") status = TransactionStatus.DECLINED auth_net_transaction_id = None rejection_reason = "Payment declined by gateway." # Handle transaction response errors (most specific) if hasattr(response, 'transactionResponse') and response.transactionResponse is not None: if hasattr(response.transactionResponse, 'errors') and response.transactionResponse.errors: try: error = response.transactionResponse.errors[0] error_code = getattr(error, 'errorCode', 'Unknown') error_text = getattr(error, 'errorText', 'Unknown error') rejection_reason = f"{error_code}: {error_text}" logger.debug(f"Transaction error: {rejection_reason}") except Exception as e: logger.warning(f"Exception parsing transaction error: {e}") # Handle message-level errors (fallback) elif hasattr(response, 'messages') and response.messages: if hasattr(response.messages, 'message') and response.messages.message: try: msg = response.messages.message if isinstance(msg, list): msg = msg[0] if msg else None if msg: code = getattr(msg, 'code', 'Unknown') text = getattr(msg, 'text', 'Unknown error') rejection_reason = f"{code}: {text}" logger.debug(f"Message error: {rejection_reason}") except Exception as e: logger.warning(f"Exception parsing message error: {e}") return status, auth_net_transaction_id, rejection_reason def create_customer_profile(customer: schemas.Customer, card_info: schemas.CardCreate): """ Creates a new customer profile in Authorize.Net (payment profiles added separately). This version sanitizes and trims customer data before sending. """ try: merchantAuth = apicontractsv1.merchantAuthenticationType(name=API_LOGIN_ID, transactionKey=TRANSACTION_KEY) except Exception as e: logger.error(f"Failed to create merchant authentication: {e}") raise ValueError("Payment gateway authentication failed. Please check API credentials.") # API max lengths: email=255 email = (customer.customer_email or f"no-email-{customer.id}@example.com")[:255] customerProfile = apicontractsv1.customerProfileType( merchantCustomerId=str(customer.id), email=email # No paymentProfiles - will be added separately ) request = apicontractsv1.createCustomerProfileRequest( merchantAuthentication=merchantAuth, profile=customerProfile ) controller = createCustomerProfileController(request) controller.execute() response = controller.getresponse() # Check if response is None (API call failed) if response is None: logger.error("ERROR: Authorize.net API call returned None - likely a network/connectivity issue") raise ValueError("Could not connect to the payment gateway. Please check network connectivity.") try: if response.messages.resultCode == "Ok": profile_id = response.customerProfileId logger.debug(profile_id) # # Payment profile ID is not available since profiles are added separately # payment_id = "" logger.debug(f"SUCCESS: Created customer profile: {profile_id} (payment profiles added separately)") # Add detailed logging logger.debug(f"API Response - Profile ID: {profile_id}") logger.debug(f"Returning: profile_id='{str(profile_id)}'") return str(profile_id) else: error_msg = _get_authnet_error_message(response) logger.debug(f"Failed to create customer profile (API Error): {error_msg}") raise ValueError(error_msg) except ValueError: # Re-raise specific ValueError messages we already set above (like E00039) raise except Exception as e: logger.debug(f"A critical exception occurred during the API call: {traceback.format_exc()}") raise ValueError("Could not connect to the payment gateway.") def authorize_customer_profile(customer_profile_id: str, payment_profile_id: str, transaction_req: schemas.TransactionAuthorizeByCardID, db_session=None, customer_id=None, card_id=None): """ Creates an AUTH_ONLY transaction against a customer profile with automatic E00121 recovery. This holds funds but doesn't capture them, and automatically recovers from invalid payment profiles. """ logger.debug(f"Authorizing profile {customer_profile_id} / payment {payment_profile_id} for ${transaction_req.preauthorize_amount}") # Validate inputs if not customer_profile_id or customer_profile_id.strip() == "": logger.debug("INVALID: customer_profile_id is None or empty") if not payment_profile_id or payment_profile_id.strip() == "": logger.debug("INVALID: payment_profile_id is None or empty") logger.debug("Payment profile ID must be a valid, non-empty string") # FIRST ATTEMPT - Normal authorization logger.debug("TRANSACTION ATTEMPT 1: Standard authorization") response = _perform_authorization(customer_profile_id, payment_profile_id, transaction_req) # CHECK FOR E00121 ERROR - "invalid payment profile ID" if db_session and customer_id and card_id and _is_e00121_response(response): logger.debug(f"E00121 DETECTED! Invalid payment profile {payment_profile_id}") logger.debug(f"AUTO-RECOVERING: Starting payment profile refresh for customer {customer_id}") try: # GET CUSTOMER PROFILE ID (since we have customer_id but need profile_id) from .. import crud customer = crud.get_customer(db_session, customer_id) if customer: # REFRESH ALL PAYMENT PROFILES FOR THIS CUSTOMER logger.debug(f"CALLING REFRESH: customer_id={customer_id}, profile_id={customer.auth_net_profile_id}") from .user_create import refresh_customer_payment_profiles refresh_customer_payment_profiles(db_session, customer_id, customer.auth_net_profile_id) # GET THE UPDATED CARD WITH NEW PAYMENT PROFILE ID updated_card = crud.get_card_by_id(db_session, card_id) if updated_card and updated_card.auth_net_payment_profile_id != payment_profile_id: new_payment_profile_id = updated_card.auth_net_payment_profile_id logger.debug(f"RECOVERY SUCCESS: Old ID '{payment_profile_id}' → New ID '{new_payment_profile_id}'") # SECOND ATTEMPT - With refreshed payment profile ID logger.debug("TRANSACTION ATTEMPT 2: Retry with refreshed payment profile") response = _perform_authorization(customer_profile_id, new_payment_profile_id, transaction_req) if _is_e00121_response(response): logger.debug("E00121 STILL PERSISTS after refresh - manual intervention may be needed") logger.debug(f"Payment profile {new_payment_profile_id} also rejected by Authorize.Net") else: logger.debug(f"SUCCESS! E00121 RESOLVED - Transaction succeeded with refreshed payment profile {new_payment_profile_id}") else: logger.debug(f"RECOVERY FAILED: No updated payment profile ID found for card {card_id}") logger.debug("Database refresh did not provide new payment profile ID") else: logger.debug(f"RECOVERY FAILED: Customer {customer_id} not found in database") except Exception as e: logger.debug(f"AUTO-RECOVERY FAILED: {str(e)}") logger.debug("Exception during payment profile refresh process") return response def _perform_authorization(customer_profile_id: str, payment_profile_id: str, transaction_req: schemas.TransactionAuthorizeByCardID): """ Perform the actual Authorize.Net authorization call. """ merchantAuth = apicontractsv1.merchantAuthenticationType(name=API_LOGIN_ID, transactionKey=TRANSACTION_KEY) profile_to_authorize = apicontractsv1.customerProfilePaymentType() profile_to_authorize.customerProfileId = customer_profile_id profile_to_authorize.customerPaymentProfileId = payment_profile_id transactionRequest = apicontractsv1.transactionRequestType( transactionType="authOnlyTransaction", amount=f"{transaction_req.preauthorize_amount:.2f}", profile=profile_to_authorize ) if transaction_req.tax_amount and transaction_req.tax_amount > 0: transactionRequest.tax = apicontractsv1.extendedAmountType(amount=f"{transaction_req.tax_amount:.2f}", name="Sales Tax") createtransactionrequest = apicontractsv1.createTransactionRequest( merchantAuthentication=merchantAuth, transactionRequest=transactionRequest ) controller = createTransactionController(createtransactionrequest) controller.execute() response = controller.getresponse() # Log response details if response is not None and hasattr(response, 'messages'): result_code = getattr(response.messages, 'resultCode', 'Unknown') logger.debug(f"✅ Authorize response: resultCode='{result_code}'") else: logger.debug("✅ Authorize response: No standard response structure") return response def capture_authorized_transaction(transaction_req: schemas.TransactionCapture): """Captures a previously authorized transaction.""" logger.debug(f"Capturing transaction {transaction_req.auth_net_transaction_id} for {transaction_req.charge_amount}") merchantAuth = apicontractsv1.merchantAuthenticationType(name=API_LOGIN_ID, transactionKey=TRANSACTION_KEY) transactionRequest = apicontractsv1.transactionRequestType( transactionType="priorAuthCaptureTransaction", amount=f"{transaction_req.charge_amount:.2f}", refTransId=transaction_req.auth_net_transaction_id ) createtransactionrequest = apicontractsv1.createTransactionRequest( merchantAuthentication=merchantAuth, transactionRequest=transactionRequest ) controller = createTransactionController(createtransactionrequest) controller.execute() return controller.getresponse() def add_payment_profile_to_customer(customer_profile_id: str, customer: schemas.Customer, card_info: schemas.CardCreate, is_default: bool = False): logger.debug(f"Adding {'default ' if is_default else ''}payment profile to Auth.Net customer profile ID: {customer_profile_id}") merchantAuth = apicontractsv1.merchantAuthenticationType(name=API_LOGIN_ID, transactionKey=TRANSACTION_KEY) first_name = sanitize_input(customer.customer_first_name, 50) or "N/A" last_name = sanitize_input(customer.customer_last_name, 50) or "N/A" address = sanitize_input(customer.customer_address, 60, allow_spaces=True) or "N/A" city = sanitize_input(customer.customer_town, 40) or "N/A" # Convert state ID (integer) to abbreviation using the mapping # customer.customer_state is an integer foreign key, not a string state = STATE_ID_TO_ABBREVIATION.get(customer.customer_state, "MA") zip_code = sanitize_input(customer.customer_zip, 20, is_zip=True) # Parse expiration date - expected format is "YYYY-MM" (validated by schema) expiration_year = int(card_info.expiration_date.split('-')[0]) expiration_month = int(card_info.expiration_date.split('-')[1]) expiration_date = f"{expiration_month:02d}{expiration_year % 100:02d}" creditCard = apicontractsv1.creditCardType( cardNumber=card_info.card_number, expirationDate=expiration_date, cardCode=card_info.cvv ) billTo = apicontractsv1.customerAddressType( firstName=first_name, lastName=last_name, address=address, city=city, state=state, zip=zip_code, country="USA" ) paymentProfile = apicontractsv1.customerPaymentProfileType( billTo=billTo, payment=apicontractsv1.paymentType(creditCard=creditCard), defaultPaymentProfile=is_default ) request = apicontractsv1.createCustomerPaymentProfileRequest( merchantAuthentication=merchantAuth, customerProfileId=customer_profile_id, paymentProfile=paymentProfile, # ========= CHANGE 2.B: USE liveMode ========= validationMode=VALIDATION_MODE ) controller = createCustomerPaymentProfileController(request) try: controller.execute() response = controller.getresponse() # Check if response is None (API call failed) if response is None: logger.error("ERROR: Authorize.net API call returned None - likely a network/connectivity issue") raise ValueError("Could not connect to the payment gateway. Please check network connectivity.") if response.messages.resultCode == "Ok": # Fix: Proper payment profile ID extraction (same bug fix as above) if hasattr(response, 'customerPaymentProfileId') and response.customerPaymentProfileId: return str(response.customerPaymentProfileId) else: logger.debug("WARNING: Added payment profile but no ID returned") raise ValueError("Payment profile created but ID not found in response") else: error_msg = _get_authnet_error_message(response) logger.debug(f"Failed to add payment profile: {error_msg}") raise ValueError(error_msg) except Exception as e: logger.debug(f"A critical exception occurred during the API call: {traceback.format_exc()}") raise ValueError("Could not connect to the payment gateway.") def get_customer_payment_profiles(customer_profile_id: str): """ Retrieves all payment profile IDs for a given customer profile from Authorize.net. Returns a list of payment profile IDs in the order they were created. """ logger.debug(f"Retrieving payment profiles for customer profile ID: {customer_profile_id}") merchantAuth = apicontractsv1.merchantAuthenticationType(name=API_LOGIN_ID, transactionKey=TRANSACTION_KEY) # Create request to get customer profile request = apicontractsv1.getCustomerProfileRequest( merchantAuthentication=merchantAuth, customerProfileId=customer_profile_id ) controller = getCustomerProfileController(request) try: controller.execute() response = controller.getresponse() # Check if response is None (API call failed) if response is None: logger.error("ERROR: Authorize.net API call returned None - likely a network/connectivity issue") raise ValueError("Could not connect to the payment gateway. Please check network connectivity.") if response.messages.resultCode == "Ok": payment_profile_ids = [] if response.profile.paymentProfiles is not None: for profile in response.profile.paymentProfiles: payment_profile_ids.append(str(profile.customerPaymentProfileId)) logger.debug(f"Retrieved {len(payment_profile_ids)} payment profile IDs for profile {customer_profile_id}") return payment_profile_ids else: error_msg = _get_authnet_error_message(response) logger.debug(f"Failed to retrieve customer profile {customer_profile_id}: {error_msg}") raise ValueError(f"Could not retrieve customer profile: {error_msg}") except Exception as e: logger.debug(f"Critical exception while retrieving customer profile {customer_profile_id}: {traceback.format_exc()}") raise ValueError("Could not connect to the payment gateway.") def charge_customer_profile(customer_profile_id: str, payment_profile_id: str, transaction_req: schemas.TransactionCreateByCardID): """ Creates an AUTH_CAPTURE transaction (charge now) against a customer profile. This charges the customer immediately for the full amount. """ logger.debug(f"Charging profile {customer_profile_id} / payment {payment_profile_id} for ${transaction_req.charge_amount}") merchantAuth = apicontractsv1.merchantAuthenticationType(name=API_LOGIN_ID, transactionKey=TRANSACTION_KEY) profile_to_charge = apicontractsv1.customerProfilePaymentType() profile_to_charge.customerProfileId = customer_profile_id profile_to_charge.customerPaymentProfileId = payment_profile_id transactionRequest = apicontractsv1.transactionRequestType( transactionType="authCaptureTransaction", amount=f"{transaction_req.charge_amount:.2f}", profile=profile_to_charge ) createtransactionrequest = apicontractsv1.createTransactionRequest( merchantAuthentication=merchantAuth, transactionRequest=transactionRequest ) controller = createTransactionController(createtransactionrequest) controller.execute() # The response is returned directly to the router to be parsed there return controller.getresponse()