Files
eamco_authorize/app/services/payment_service.py
Edwin Eames 97261f6c51 Refactor payment service, fix DB session, and consolidate endpoints
- Fix critical NameError in database.py by restoring Session factory
- Refactor payment_service.py and crud.py to use shared constants.py and utils.py
- Deduplicate state mapping and input sanitization logic
- Move transaction amount calculation logic from CRUD to Router layer
- Enforce type safety in schemas using IntEnum for TransactionType/Status
- Move capture endpoint from transaction.py to payment.py (now /payments/capture)
- Update create_customer_profile signature for clarity
2026-02-01 12:31:42 -05:00

523 lines
23 KiB
Python

"""
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
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
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()