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,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
)

View File

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