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:
2026-02-01 12:31:42 -05:00
parent 449eb74279
commit 97261f6c51
13 changed files with 335 additions and 428 deletions

View File

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