From 97261f6c519ed4e15611ca37cf160d9c0f563f01 Mon Sep 17 00:00:00 2001 From: Edwin Eames Date: Sun, 1 Feb 2026 12:31:42 -0500 Subject: [PATCH] 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 --- app/constants.py | 28 ++++ app/crud.py | 41 ++--- app/database.py | 7 +- app/models.py | 4 +- app/routers/payment.py | 180 +++++++--------------- app/routers/transaction.py | 50 +------ app/schemas.py | 8 +- app/services/check_user_service.py | 36 +---- app/services/payment_service.py | 233 ++++++++++++++++------------- app/services/user_create.py | 32 ++-- app/services/user_delete.py | 69 ++------- app/utils.py | 28 ++++ config.py | 47 +++++- 13 files changed, 335 insertions(+), 428 deletions(-) create mode 100644 app/constants.py create mode 100644 app/utils.py diff --git a/app/constants.py b/app/constants.py new file mode 100644 index 0000000..3f6eb4b --- /dev/null +++ b/app/constants.py @@ -0,0 +1,28 @@ +""" +Constants shared across the EAMCO Authorize service. +""" +import enum + +class TransactionStatus(enum.IntEnum): + """Transaction status codes used throughout the payment system.""" + APPROVED = 0 + DECLINED = 1 + + +class TransactionType(enum.IntEnum): + """Transaction type codes for different payment operations.""" + CHARGE = 0 + AUTHORIZE = 1 + CAPTURE = 2 + +# State ID to abbreviation mapping for Authorize.net billing address +# This maps the integer state IDs from the database to state abbreviations +STATE_ID_TO_ABBREVIATION = { + 0: "MA", + 1: "RI", + 2: "NH", + 3: "ME", + 4: "VT", + 5: "CT", + 6: "NY" +} diff --git a/app/crud.py b/app/crud.py index 11ffa91..bcde481 100644 --- a/app/crud.py +++ b/app/crud.py @@ -1,17 +1,21 @@ -## File: your_app/crud.py +""" +CRUD operations for the EAMCO Authorize service. +""" import logging from sqlalchemy.orm import Session from . import models, schemas +from .constants import TransactionStatus, TransactionType from decimal import Decimal +from typing import Optional, List logger = logging.getLogger(__name__) # --- NEW CRUD FUNCTIONS FOR CIM --- -def get_card_by_id(db: Session, card_id: int): +def get_card_by_id(db: Session, card_id: int) -> Optional[models.Card]: return db.query(models.Card).filter(models.Card.id == card_id).first() -def update_customer_auth_net_profile_id(db: Session, customer_id: int, profile_id: str): +def update_customer_auth_net_profile_id(db: Session, customer_id: int, profile_id: str) -> Optional[models.Customer]: db_customer = get_customer(db, customer_id) if db_customer: db_customer.auth_net_profile_id = profile_id @@ -19,13 +23,10 @@ def update_customer_auth_net_profile_id(db: Session, customer_id: int, profile_i db.refresh(db_customer) return db_customer -def create_customer_card(db: Session, customer_id: int, card_info: schemas.CardCreate, payment_profile_id: str): +def create_customer_card(db: Session, customer_id: int, card_info: schemas.CardCreate, payment_profile_id: str) -> models.Card: last_four_digits = card_info.card_number[-4:] - try: - exp_year, exp_month = map(int, card_info.expiration_date.split('-')) - except ValueError: - # Handle cases like "MM/YY" if necessary, but "YYYY-MM" is better - raise ValueError("Expiration date must be in YYYY-MM format") + # Schema guarantees YYYY-MM format + exp_year, exp_month = map(int, card_info.expiration_date.split('-')) db_card = models.Card( user_id=customer_id, @@ -57,10 +58,11 @@ def get_customer_by_email(db: Session, email: str): def get_customers(db: Session, skip: int = 0, limit: int = 100): return db.query(models.Customer).offset(skip).limit(limit).all() -def create_transaction(db: Session, transaction: schemas.TransactionBase, customer_id: int, status: int, auth_net_transaction_id: str = None): - # Using your existing logic for saving amounts - preauthorize_amount = transaction.preauthorize_amount if status == 0 else Decimal("0.0") - charge_amount = transaction.charge_amount if (transaction.transaction_type != 1 and status == 0) else Decimal("0.0") +def create_transaction(db: Session, transaction: schemas.TransactionBase, customer_id: int, status: int, auth_net_transaction_id: str = None) -> models.Transaction: + # Logic for amounts moved to routers/payment.py + # We simply save what is passed in the transaction object + preauthorize_amount = transaction.preauthorize_amount or Decimal("0.0") + charge_amount = transaction.charge_amount or Decimal("0.0") db_transaction = models.Transaction( preauthorize_amount=preauthorize_amount, @@ -71,6 +73,7 @@ def create_transaction(db: Session, transaction: schemas.TransactionBase, custom auth_net_transaction_id=auth_net_transaction_id, service_id=transaction.service_id, delivery_id=transaction.delivery_id, + auto_id=transaction.auto_id, card_id=transaction.card_id, payment_gateway=transaction.payment_gateway, rejection_reason=transaction.rejection_reason @@ -83,8 +86,8 @@ def create_transaction(db: Session, transaction: schemas.TransactionBase, custom def get_transaction_by_delivery_id(db: Session, delivery_id: int): return db.query(models.Transaction).filter( models.Transaction.delivery_id == delivery_id, - models.Transaction.transaction_type == 1, - models.Transaction.status == 0 + models.Transaction.transaction_type == TransactionType.AUTHORIZE, + models.Transaction.status == TransactionStatus.APPROVED ).first() def get_transaction_by_auth_id(db: Session, auth_net_transaction_id: str): @@ -95,8 +98,8 @@ def get_transaction_by_auth_id(db: Session, auth_net_transaction_id: str): def get_transaction_by_auto_id(db: Session, auto_id: int): return db.query(models.Transaction).filter( models.Transaction.auto_id == auto_id, - models.Transaction.transaction_type.in_([1, 2]), - models.Transaction.status == 0 + models.Transaction.transaction_type.in_([TransactionType.AUTHORIZE, TransactionType.CAPTURE]), + models.Transaction.status == TransactionStatus.APPROVED ).first() def update_transaction_for_capture(db: Session, auth_net_transaction_id: str, charge_amount: Decimal, status: int, rejection_reason: str = None): @@ -104,8 +107,8 @@ def update_transaction_for_capture(db: Session, auth_net_transaction_id: str, ch if not transaction: return None - transaction.charge_amount = charge_amount if status == 0 else Decimal("0.0") - transaction.transaction_type = 2 + transaction.charge_amount = charge_amount if status == TransactionStatus.APPROVED else Decimal("0.0") + transaction.transaction_type = TransactionType.CAPTURE transaction.status = status if rejection_reason: transaction.rejection_reason = rejection_reason diff --git a/app/database.py b/app/database.py index 6672a0d..685f3df 100644 --- a/app/database.py +++ b/app/database.py @@ -25,11 +25,10 @@ engine = create_engine( pool_recycle=3600, # Recycle connections after 1 hour ) -Session = sessionmaker(autocommit=False, autoflush=False, bind=engine) -session = Session() - Base = declarative_base() -Base.metadata.create_all(engine) + + +Session = sessionmaker(autocommit=False, autoflush=False, bind=engine) def get_db(): diff --git a/app/models.py b/app/models.py index e635e7a..b5fbdef 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,4 @@ -## File: your_app/models.py +## File: app/models.py from sqlalchemy import Column, Integer, String, DateTime, Boolean, ForeignKey, Numeric from .database import Base @@ -118,7 +118,7 @@ class Tickets_Auto_Delivery(Base): price_per_gallon = Column(Numeric(10, 2), nullable=True) total_amount_customer = Column(Numeric(10, 2), nullable=True) - customer_town = Column(String(140)) + # customer_town removed (duplicate) payment_type = Column(Integer, nullable=True) payment_card_id = Column(Integer, nullable=True) diff --git a/app/routers/payment.py b/app/routers/payment.py index 9ab1a81..3b58f40 100644 --- a/app/routers/payment.py +++ b/app/routers/payment.py @@ -1,133 +1,25 @@ -## File: your_app/views.py - +""" +Payment Router - Main payment processing endpoints for card management and transactions. +""" import logging from fastapi import APIRouter, Depends, HTTPException - -logger = logging.getLogger(__name__) from sqlalchemy.orm import Session -from typing import Tuple, Optional -import enum +from decimal import Decimal from .. import crud, models, schemas, database from ..services import payment_service +from ..services.payment_service import parse_authnet_response +from ..constants import TransactionStatus, TransactionType, STATE_ID_TO_ABBREVIATION from config import load_config +logger = logging.getLogger(__name__) ApplicationConfig = load_config() - - - -AuthNetResponse = object - router = APIRouter( prefix="/payments", tags=["Payments & Transactions"], ) -class TransactionStatus(enum.IntEnum): - APPROVED = 0 - DECLINED = 1 - -class TransactionType(enum.IntEnum): - CHARGE = 0 - AUTHORIZE = 1 - CAPTURE = 3 - - - -# --- NEW CIM CORE FUNCTIONS --- -STATE_ID_TO_ABBREVIATION = { -0: "MA", -1: "RI", -2: "NH", -3: "ME", -4: "VT", -5: "CT", -6: "NY" -} - -def _parse_authnet_response(response: Optional[AuthNetResponse]) -> Tuple[TransactionStatus, Optional[str], Optional[str]]: - """ - Parse Authorize.net response with proper attribute access for SDK objects. - Authorize.net response objects don't have .text properties, they're direct attributes. - """ - logger.debug(f"DEBUG: Parsing response, type: {type(response)}") - logger.debug(f"DEBUG: Response exists: {response is not None}") - - if response is not None: - logger.debug("DEBUG: Checking for messages attribute...") - if hasattr(response, 'messages'): - logger.debug(f"DEBUG: Messages exist, resultCode: {getattr(response.messages, 'resultCode', 'NO resultCode')}") - else: - logger.debug("DEBUG: No messages attribute") - - if response.messages.resultCode == "Ok": - logger.debug("✅✅ DEBUG: Taking APPROVED path") - status = TransactionStatus.APPROVED - auth_net_transaction_id = 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"✅✅ DEBUG: FOUND transaction ID: {auth_net_transaction_id}") - else: - logger.debug("DEBUG: transactionResponse exists but no transId") - else: - logger.debug("DEBUG: No transactionResponse in approved response") - except Exception as e: - logger.debug(f"DEBUG: Exception extracting transaction ID: {e}") - logger.debug(f"DEBUG: Response object inspection:") - logger.debug(type(response)) - if hasattr(response, 'transactionResponse'): - logger.debug(f"TransactionResponse type: {type(response.transactionResponse)}") - logger.debug(dir(response.transactionResponse)) - - rejection_reason = None - logger.debug(f"✅✅✅ DEBUG: APPROVED - ID: {auth_net_transaction_id}, rejection: {rejection_reason}") - - else: - logger.debug("DEBUG: Taking DECLINED path") - status = TransactionStatus.DECLINED - auth_net_transaction_id = None - rejection_reason = "Payment declined by gateway." - - if response is not None: - # Handle transaction response errors - if hasattr(response, 'transactionResponse') and response.transactionResponse is not None: - if hasattr(response.transactionResponse, 'errors') and response.transactionResponse.errors: - logger.debug("DEBUG: Using transactionResponse.errors") - try: - error = response.transactionResponse.errors[0] - # Remove the .text access - use direct attributes - error_code = getattr(error, 'errorCode', 'Unknown') - error_text = getattr(error, 'errorText', 'Unknown error') - rejection_reason = f"{error_code}: {error_text}" - logger.debug(f"DEBUG: Transaction error: {rejection_reason}") - except Exception as e: - logger.debug(f"DEBUG: Exception parsing transaction error: {e}") - rejection_reason = "Failed to parse transaction error" - - # Handle message-level errors - elif hasattr(response, 'messages') and response.messages: - if hasattr(response.messages, 'message') and response.messages.message: - logger.debug("DEBUG: Using 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"DEBUG: Message error: {rejection_reason}") - except Exception as e: - logger.debug(f"DEBUG: Exception parsing message error: {e}") - rejection_reason = "Failed to parse message error" - - logger.debug(f"✅✅✅ DEBUG: FINAL RESULT - Status: {status}, ID: {auth_net_transaction_id}, Reason: {rejection_reason}") - return status, auth_net_transaction_id, rejection_reason @router.post("/customers/{customer_id}/cards", summary="Add a new payment card for a customer") def add_card_to_customer(customer_id: int, card_info: schemas.CardCreate, db: Session = Depends(database.get_db)): @@ -149,11 +41,19 @@ def add_card_to_customer(customer_id: int, card_info: schemas.CardCreate, db: Se try: # This part now works because the service hard-codes the state to "MA" if not db_customer.auth_net_profile_id: - profile_id, payment_id = payment_service.create_customer_profile( + # 1. Create the customer profile (returns ID, but no payment profile yet) + profile_id = payment_service.create_customer_profile( customer=customer_schema, card_info=card_info ) + # 2. Update local DB with the new profile ID crud.update_customer_auth_net_profile_id(db, customer_id=customer_id, profile_id=profile_id) - payment_profile_id = payment_id + + # 3. Explicitly add the payment profile to get the payment_profile_id + payment_profile_id = payment_service.add_payment_profile_to_customer( + customer_profile_id=profile_id, + customer=customer_schema, + card_info=card_info + ) else: payment_profile_id = payment_service.add_payment_profile_to_customer( customer_profile_id=db_customer.auth_net_profile_id, @@ -262,10 +162,16 @@ def charge_saved_card(customer_id: int, transaction_req: schemas.TransactionCrea transaction_req=transaction_req ) - status, auth_net_transaction_id, rejection_reason = _parse_authnet_response(auth_net_response) + + status, auth_net_transaction_id, rejection_reason = parse_authnet_response(auth_net_response) + + # Calculate amounts to save based on status (Business Logic moved from CRUD) + final_charge_amount = transaction_req.charge_amount if status == TransactionStatus.APPROVED else Decimal("0.0") + final_preauth_amount = Decimal("0.0") transaction_data = schemas.TransactionBase( - charge_amount=transaction_req.charge_amount, + charge_amount=final_charge_amount, + preauthorize_amount=final_preauth_amount, transaction_type=TransactionType.CHARGE, service_id=transaction_req.service_id, delivery_id=transaction_req.delivery_id, @@ -355,8 +261,7 @@ def authorize_saved_card(customer_id: int, transaction_req: schemas.TransactionA ) # Check if the test authorization worked - from ..services import payment_service as ps # Need access to _parse_authnet_response - test_status, _, test_reason = _parse_authnet_response(test_response) + test_status, _, test_reason = parse_authnet_response(test_response) if "E00121" in str(test_reason) or test_status == 1: # 1 = DECLINED logger.debug(f"🐛 DEBUG: TEST AUTH FAILED - Payment profile exists but is INVALID!") @@ -427,11 +332,17 @@ def authorize_saved_card(customer_id: int, transaction_req: schemas.TransactionA ) # Parse the transaction response (no need for E00121 nuclear cleanup since pre-validation should have caught it) - transaction_status, auth_net_transaction_id, rejection_reason = _parse_authnet_response(auth_net_response) + transaction_status, auth_net_transaction_id, rejection_reason = parse_authnet_response(auth_net_response) logger.debug(transaction_req) + + # Calculate amounts to save based on status (Business Logic moved from CRUD) + final_preauth_amount = transaction_req.preauthorize_amount if transaction_status == TransactionStatus.APPROVED else Decimal("0.0") + final_charge_amount = Decimal("0.0") + transaction_data = schemas.TransactionBase( - preauthorize_amount=transaction_req.preauthorize_amount, + preauthorize_amount=final_preauth_amount, + charge_amount=final_charge_amount, transaction_type=TransactionType.AUTHORIZE, # This is key service_id=transaction_req.service_id, delivery_id=transaction_req.delivery_id, @@ -449,3 +360,26 @@ def authorize_saved_card(customer_id: int, transaction_req: schemas.TransactionA ) return db_transaction + + +@router.post("/capture", response_model=schemas.Transaction, summary="Capture a previously authorized amount") +def capture_authorized_amount(transaction: schemas.TransactionCapture, db: Session = Depends(database.get_db)): + # This endpoint captures a previously authorized transaction + # It finds the original transaction by its ID and captures the funds + logger.info(f"POST /payments/capture - Capturing authorized transaction {transaction.auth_net_transaction_id}") + auth_transaction = crud.get_transaction_by_auth_id(db, auth_net_transaction_id=transaction.auth_net_transaction_id) + if not auth_transaction: + raise HTTPException(status_code=404, detail="Authorization transaction not found") + + # Call the capture service function + auth_net_response = payment_service.capture_authorized_transaction(transaction) + status, _, rejection_reason = parse_authnet_response(auth_net_response) + + # Use the existing CRUD function to update the transaction + return crud.update_transaction_for_capture( + db=db, + auth_net_transaction_id=transaction.auth_net_transaction_id, + charge_amount=transaction.charge_amount, + status=status, + rejection_reason=rejection_reason + ) diff --git a/app/routers/transaction.py b/app/routers/transaction.py index e56811a..8f57a9e 100644 --- a/app/routers/transaction.py +++ b/app/routers/transaction.py @@ -1,26 +1,19 @@ -## File: transaction.py (New transaction router) +""" +Transaction Router - Endpoints for transaction lookup and capture operations. +""" import logging from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.orm import Session -import enum - -# Import locally to avoid circular imports -import sys -import os -sys.path.append(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))) from app import crud, database, schemas, models from app.services import payment_service +from app.services.payment_service import parse_authnet_response, TransactionStatus logger = logging.getLogger(__name__) # Create a router for transaction endpoints transaction_router = APIRouter() -class TransactionStatus(enum.IntEnum): - APPROVED = 0 - DECLINED = 1 - # Test endpoint to verify router is working @transaction_router.get("/test/", summary="Test transaction router") def test_transaction_router(): @@ -29,9 +22,6 @@ def test_transaction_router(): return {"test": "transaction router is working"} - - - @transaction_router.get("/transaction/delivery/{delivery_id}", summary="Get pre-authorization transaction for a delivery") def get_delivery_transaction(delivery_id: int, db: Session = Depends(database.get_db)): """ @@ -84,36 +74,4 @@ def update_transaction_auto_id(transaction_id: int, new_auto_id: int, db: Sessio return {"message": "Transaction auto_id updated"} -@transaction_router.post("/capture/", response_model=schemas.Transaction, summary="Capture a previously authorized amount") -def capture_authorized_amount(transaction: schemas.TransactionCapture, db: Session = Depends(database.get_db)): - # This endpoint captures a previously authorized transaction - # It finds the original transaction by its ID and captures the funds - logger.info(f"POST /capture - Capturing authorized transaction {transaction.auth_net_transaction_id}") - auth_transaction = crud.get_transaction_by_auth_id(db, auth_net_transaction_id=transaction.auth_net_transaction_id) - if not auth_transaction: - raise HTTPException(status_code=404, detail="Authorization transaction not found") - # Call the capture service function - auth_net_response = payment_service.capture_authorized_transaction(transaction) - status, _, rejection_reason = _parse_authnet_response(auth_net_response) - - # Use the existing CRUD function to update the transaction - return crud.update_transaction_for_capture( - db=db, - auth_net_transaction_id=transaction.auth_net_transaction_id, - charge_amount=transaction.charge_amount, - status=status, - rejection_reason=rejection_reason - ) - -def _parse_authnet_response(response): - """ - Parse Authorize.Net response for transaction status - """ - if response.messages.resultCode == "Ok": - status = TransactionStatus.APPROVED - rejection_reason = None - else: - status = TransactionStatus.DECLINED - rejection_reason = "Payment declined by gateway." - return status, None, rejection_reason diff --git a/app/schemas.py b/app/schemas.py index d304a93..71504da 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -4,7 +4,9 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from typing import List, Optional from datetime import datetime from decimal import Decimal +from decimal import Decimal import re +from .constants import TransactionType, TransactionStatus # --- NEW SCHEMAS FOR CIM WORKFLOW (Now with correct Pydantic V2 config) --- @@ -68,7 +70,7 @@ class TransactionAuthorizeByCardID(BaseModel): class TransactionBase(BaseModel): preauthorize_amount: Optional[Decimal] = None charge_amount: Optional[Decimal] = None - transaction_type: int + transaction_type: TransactionType service_id: Optional[int] = None delivery_id: Optional[int] = None auto_id: Optional[int] = None @@ -94,8 +96,8 @@ class TransactionCapture(BaseModel): class Transaction(TransactionBase): id: int - transaction_type: int - status: int + transaction_type: TransactionType + status: TransactionStatus auth_net_transaction_id: Optional[str] = None customer_id: int created_at: datetime diff --git a/app/services/check_user_service.py b/app/services/check_user_service.py index 1f0ee4f..99d205a 100644 --- a/app/services/check_user_service.py +++ b/app/services/check_user_service.py @@ -10,31 +10,10 @@ from sqlalchemy.orm import Session # Set Authorize.net environment based on configuration -from authorizenet.constants import constants +from config import load_config, API_LOGIN_ID, TRANSACTION_KEY, VALIDATION_MODE, ENVIRONMENT - -from config import load_config # Assuming you have this - -# Load Authorize.net credentials -ApplicationConfig = load_config() - -# 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 @@ -193,14 +172,7 @@ def _get_customer_profile(profile_id: str): controller = controllers.getCustomerProfileController(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() diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 237570c..2217965 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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() diff --git a/app/services/user_create.py b/app/services/user_create.py index 6eb885c..231d335 100644 --- a/app/services/user_create.py +++ b/app/services/user_create.py @@ -15,27 +15,13 @@ from . import payment_service from .. import schemas # 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() - -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 @@ -242,7 +228,7 @@ def create_user_account(db: Session, customer_id: int) -> dict: } except Exception as e: - logger.debug(f"Critical exception during user account creation for customer {customer_id}: {traceback.format_exc()}") + logger.error(f"Critical exception during user account creation for customer {customer_id}: {traceback.format_exc()}") db.rollback() return { "success": False, @@ -311,7 +297,7 @@ def refresh_customer_payment_profiles(db: Session, customer_id: int, auth_profil ) # Check test result - _, _, test_reason = payment_service._parse_authnet_response(test_response) + _, _, test_reason = payment_service.parse_authnet_response(test_response) if "E00121" in str(test_reason): cards_need_update.append(card) logger.debug(f"🔄 Card {card.id} has profile {card.auth_net_payment_profile_id} that EXISTS but is CORRUPTED - NEEDS RECREATION") @@ -373,7 +359,7 @@ def refresh_customer_payment_profiles(db: Session, customer_id: int, auth_profil recreated_cards.append(card) logger.debug(f"✅ Successfully recreated payment profile {new_payment_profile_id} for card {card.id}") else: - logger.debug(f"❌ Failed to recreate payment profile for card {card.id} - no ID returned") + logger.error(f"❌ Failed to recreate payment profile for card {card.id} - no ID returned") except Exception as e: logger.debug(f"❌ Failed to recreate payment profile for card {card.id}: {str(e)}") @@ -383,7 +369,7 @@ def refresh_customer_payment_profiles(db: Session, customer_id: int, auth_profil db.commit() logger.debug(f"✅ Successfully recreated and saved {len(recreated_cards)} payment profiles") else: - logger.debug("❌ No payment profiles could be recreated - this is a critical failure") + logger.error("❌ No payment profiles could be recreated - this is a critical failure") return False else: logger.debug(f"🔄 All {len(cards_before)} cards have valid payment profile IDs") diff --git a/app/services/user_delete.py b/app/services/user_delete.py index 058ec83..0c65282 100644 --- a/app/services/user_delete.py +++ b/app/services/user_delete.py @@ -11,55 +11,18 @@ from authorizenet.constants import constants from config import load_config from sqlalchemy.orm import Session +# Load Authorize.net credentials +from config import load_config, API_LOGIN_ID, TRANSACTION_KEY, VALIDATION_MODE, ENVIRONMENT + # Load Authorize.net credentials ApplicationConfig = load_config() - - -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 -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.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." +from .payment_service import _get_authnet_error_message def delete_user_account(db: Session, customer_id: int) -> dict: @@ -161,7 +124,7 @@ def delete_user_account(db: Session, customer_id: int) -> dict: } except Exception as e: - logger.debug(f"Critical exception during account deletion for customer {customer_id}: {traceback.format_exc()}") + logger.error(f"Critical exception during account deletion for customer {customer_id}: {traceback.format_exc()}") db.rollback() return { "success": False, @@ -192,14 +155,7 @@ def _delete_customer_profile(profile_id: str) -> bool: ) controller = deleteCustomerProfileController(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() if response is None: @@ -247,14 +203,7 @@ def _delete_payment_profile(customer_profile_id: str, payment_profile_id: str) - ) controller = deleteCustomerPaymentProfileController(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() if response is None: diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..a88b6a2 --- /dev/null +++ b/app/utils.py @@ -0,0 +1,28 @@ +""" +Utility functions shared across the EAMCO Authorize service. +""" +import re + +def sanitize_input(text, max_len, allow_spaces=False, is_zip=False): + """ + Sanitize input string by removing invalid characters. + + Args: + text: Input text/number/value + max_len: Maximum length of the output string + allow_spaces: If True, allows spaces in the output + is_zip: If True, allows hyphens (for ZIP+4) + + Returns: + Sanitized string truncated to max_len + """ + 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] diff --git a/config.py b/config.py index 026b029..c69fdcc 100644 --- a/config.py +++ b/config.py @@ -1,24 +1,55 @@ import os -def load_config(mode=os.environ.get('MODE')): +# Authorize.net Configuration +from authorizenet.constants import constants +def load_config(mode=os.environ.get('MODE')): + """ + Load the application configuration based on the environment mode. + Sets up Authorize.net environment variables on the config object. + """ + config_class = None + try: if mode == 'PRODUCTION': from settings_prod import ApplicationConfig - return ApplicationConfig + config_class = ApplicationConfig elif mode == 'LOCAL': from settings_local import ApplicationConfig - return ApplicationConfig + config_class = ApplicationConfig elif mode == 'DEVELOPMENT': - from settings_dev import ApplicationConfig - return ApplicationConfig + config_class = ApplicationConfig else: - pass + # Default to dev if unknown mode + from settings_dev import ApplicationConfig + config_class = ApplicationConfig except ImportError: - + # Fallback from settings_dev import ApplicationConfig - return ApplicationConfig + config_class = ApplicationConfig + + # Set Authorize.net specific settings on the config class + if config_class.CURRENT_SETTINGS == 'PRODUCTION': + config_class.ENVIRONMENT = constants.PRODUCTION + config_class.VALIDATION_MODE = "liveMode" + elif config_class.CURRENT_SETTINGS == 'LOCAL': + config_class.ENVIRONMENT = constants.PRODUCTION + config_class.VALIDATION_MODE = "liveMode" + else: + config_class.ENVIRONMENT = constants.SANDBOX + config_class.VALIDATION_MODE = "testMode" + + return config_class + +# Load the configuration once +ApplicationConfig = load_config() + +# Export variables for compatibility with imports in other files +ENVIRONMENT = ApplicationConfig.ENVIRONMENT +VALIDATION_MODE = ApplicationConfig.VALIDATION_MODE +API_LOGIN_ID = ApplicationConfig.API_LOGIN_ID +TRANSACTION_KEY = ApplicationConfig.TRANSACTION_KEY