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
This commit is contained in:
@@ -1,8 +1,15 @@
|
||||
## File: your_app/services/payment_service.py
|
||||
"""
|
||||
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__)
|
||||
@@ -14,29 +21,19 @@ from authorizenet.apicontrollers import (
|
||||
)
|
||||
from authorizenet.constants import constants
|
||||
from .. import schemas
|
||||
from config import load_config # Assuming you have this
|
||||
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 Authorize.net environment based on configuration
|
||||
# Set Authorize.net environment based on configuration
|
||||
if ApplicationConfig.CURRENT_SETTINGS == 'PRODUCTION':
|
||||
constants.environment = constants.PRODUCTION
|
||||
VALIDATION_MODE = "liveMode"
|
||||
API_LOGIN_ID = ApplicationConfig.API_LOGIN_ID
|
||||
TRANSACTION_KEY = ApplicationConfig.TRANSACTION_KEY
|
||||
elif ApplicationConfig.CURRENT_SETTINGS == 'LOCAL':
|
||||
constants.environment = constants.PRODUCTION
|
||||
VALIDATION_MODE = "liveMode"
|
||||
API_LOGIN_ID = ApplicationConfig.API_LOGIN_ID
|
||||
TRANSACTION_KEY = ApplicationConfig.TRANSACTION_KEY
|
||||
else:
|
||||
constants.environment = constants.SANDBOX
|
||||
constants.show_url_on_request = True
|
||||
VALIDATION_MODE = "testMode"
|
||||
API_LOGIN_ID = ApplicationConfig.API_LOGIN_ID
|
||||
TRANSACTION_KEY = ApplicationConfig.TRANSACTION_KEY
|
||||
# Set environment
|
||||
constants.environment = ENVIRONMENT
|
||||
|
||||
|
||||
|
||||
@@ -98,16 +95,99 @@ def _get_authnet_error_message(response):
|
||||
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.
|
||||
"""
|
||||
# Note: Never log API credentials
|
||||
try:
|
||||
merchantAuth = apicontractsv1.merchantAuthenticationType(name=API_LOGIN_ID, transactionKey=TRANSACTION_KEY)
|
||||
except Exception as e:
|
||||
pass # Will be handled by request failure below
|
||||
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]
|
||||
@@ -126,19 +206,12 @@ def create_customer_profile(customer: schemas.Customer, card_info: schemas.CardC
|
||||
controller = createCustomerProfileController(request)
|
||||
|
||||
|
||||
if ApplicationConfig.CURRENT_SETTINGS == 'PRODUCTION':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
elif ApplicationConfig.CURRENT_SETTINGS == 'LOCAL':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
else:
|
||||
controller.execute()
|
||||
controller.execute()
|
||||
response = controller.getresponse()
|
||||
|
||||
# Check if response is None (API call failed)
|
||||
if response is None:
|
||||
logger.debug("ERROR: Authorize.net API call returned None - likely a network/connectivity issue")
|
||||
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:
|
||||
@@ -151,9 +224,9 @@ def create_customer_profile(customer: schemas.Customer, card_info: schemas.CardC
|
||||
|
||||
# Add detailed logging
|
||||
logger.debug(f"API Response - Profile ID: {profile_id}")
|
||||
logger.debug(f"Returning: profile_id='{str(profile_id)}', payment_id=''")
|
||||
logger.debug(f"Returning: profile_id='{str(profile_id)}'")
|
||||
|
||||
return 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}")
|
||||
@@ -190,7 +263,6 @@ def authorize_customer_profile(customer_profile_id: str, payment_profile_id: str
|
||||
# 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("POOOP")
|
||||
logger.debug(f"AUTO-RECOVERING: Starting payment profile refresh for customer {customer_id}")
|
||||
|
||||
try:
|
||||
@@ -256,14 +328,7 @@ def _perform_authorization(customer_profile_id: str, payment_profile_id: str, tr
|
||||
|
||||
controller = createTransactionController(createtransactionrequest)
|
||||
|
||||
if ApplicationConfig.CURRENT_SETTINGS == 'PRODUCTION':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
elif ApplicationConfig.CURRENT_SETTINGS == 'LOCAL':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
else:
|
||||
controller.execute()
|
||||
controller.execute()
|
||||
|
||||
response = controller.getresponse()
|
||||
|
||||
@@ -298,14 +363,7 @@ def capture_authorized_transaction(transaction_req: schemas.TransactionCapture):
|
||||
)
|
||||
|
||||
controller = createTransactionController(createtransactionrequest)
|
||||
if ApplicationConfig.CURRENT_SETTINGS == 'PRODUCTION':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
elif ApplicationConfig.CURRENT_SETTINGS == 'LOCAL':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
else:
|
||||
controller.execute()
|
||||
controller.execute()
|
||||
|
||||
return controller.getresponse()
|
||||
|
||||
@@ -315,39 +373,21 @@ def add_payment_profile_to_customer(customer_profile_id: str, customer: schemas.
|
||||
|
||||
merchantAuth = apicontractsv1.merchantAuthenticationType(name=API_LOGIN_ID, transactionKey=TRANSACTION_KEY)
|
||||
|
||||
def sanitize(text, max_len, allow_spaces=False, is_zip=False):
|
||||
if not text:
|
||||
return ""
|
||||
if is_zip:
|
||||
pattern = r'[^a-zA-Z0-9-]'
|
||||
else:
|
||||
pattern = r'[^a-zA-Z0-9]' if not allow_spaces else r'[^a-zA-Z0-9\s]'
|
||||
sanitized = re.sub(pattern, '', str(text))
|
||||
return sanitized.strip()[:max_len]
|
||||
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"
|
||||
|
||||
first_name = sanitize(customer.customer_first_name, 50) or "N/A"
|
||||
last_name = sanitize(customer.customer_last_name, 50) or "N/A"
|
||||
address = sanitize(customer.customer_address, 60, allow_spaces=True) or "N/A"
|
||||
city = sanitize(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")
|
||||
|
||||
# ========= CHANGE 1.B: ADD STATE HERE =========
|
||||
state = sanitize(customer.customer_state, 40) or "MA" # Defaulting to MA for safety
|
||||
zip_code = sanitize_input(customer.customer_zip, 20, is_zip=True)
|
||||
|
||||
zip_code = sanitize(customer.customer_zip, 20, is_zip=True)
|
||||
|
||||
# Fix expiration date format for cards
|
||||
try:
|
||||
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}"
|
||||
except (ValueError, IndexError):
|
||||
sanitized_exp = card_info.expiration_date.replace('/', '').replace('-', '')
|
||||
if len(sanitized_exp) == 4:
|
||||
expiration_date = sanitized_exp
|
||||
else:
|
||||
expiration_date = "0325"
|
||||
|
||||
# Expiration date parsed successfully
|
||||
# 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,
|
||||
@@ -382,20 +422,12 @@ def add_payment_profile_to_customer(customer_profile_id: str, customer: schemas.
|
||||
controller = createCustomerPaymentProfileController(request)
|
||||
|
||||
try:
|
||||
if ApplicationConfig.CURRENT_SETTINGS == 'PRODUCTION':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
elif ApplicationConfig.CURRENT_SETTINGS == 'LOCAL':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
else:
|
||||
controller.execute()
|
||||
|
||||
controller.execute()
|
||||
response = controller.getresponse()
|
||||
|
||||
# Check if response is None (API call failed)
|
||||
if response is None:
|
||||
logger.debug("ERROR: Authorize.net API call returned None - likely a network/connectivity issue")
|
||||
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":
|
||||
@@ -433,20 +465,12 @@ def get_customer_payment_profiles(customer_profile_id: str):
|
||||
controller = getCustomerProfileController(request)
|
||||
|
||||
try:
|
||||
if ApplicationConfig.CURRENT_SETTINGS == 'PRODUCTION':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
elif ApplicationConfig.CURRENT_SETTINGS == 'LOCAL':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
else:
|
||||
controller.execute()
|
||||
|
||||
controller.execute()
|
||||
response = controller.getresponse()
|
||||
|
||||
# Check if response is None (API call failed)
|
||||
if response is None:
|
||||
logger.debug("ERROR: Authorize.net API call returned None - likely a network/connectivity issue")
|
||||
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":
|
||||
@@ -492,14 +516,7 @@ def charge_customer_profile(customer_profile_id: str, payment_profile_id: str, t
|
||||
)
|
||||
|
||||
controller = createTransactionController(createtransactionrequest)
|
||||
if ApplicationConfig.CURRENT_SETTINGS == 'PRODUCTION':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
elif ApplicationConfig.CURRENT_SETTINGS == 'LOCAL':
|
||||
controller.setenvironment(constants.PRODUCTION)
|
||||
controller.execute()
|
||||
else:
|
||||
controller.execute()
|
||||
controller.execute()
|
||||
|
||||
# The response is returned directly to the router to be parsed there
|
||||
return controller.getresponse()
|
||||
|
||||
Reference in New Issue
Block a user