1004 lines
37 KiB
Python
1004 lines
37 KiB
Python
from fastapi import APIRouter, Depends, HTTPException
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
from decimal import Decimal
|
|
import requests
|
|
import logging
|
|
from typing import Dict, Any, Optional, Tuple
|
|
|
|
from database import get_db
|
|
from models import Card, Transaction, Customer_Customer, Account_User
|
|
from schemas import PaymentCreate, PaymentResponse, CardCreate, CardResponse, CardUpdate
|
|
from routes.auth.current_user import get_current_user
|
|
from config import load_config
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# State code to abbreviation mapping
|
|
STATE_MAPPING = {0: "MA", 1: "NH"} # Add more states as needed
|
|
|
|
# Authorize.net API endpoints
|
|
AUTHNET_SANDBOX_URL = "https://apitest.authorize.net/xml/v1/request.api"
|
|
AUTHNET_PRODUCTION_URL = "https://api.authorize.net/xml/v1/request.api"
|
|
|
|
|
|
def get_auth_net_credentials() -> Tuple[str, str]:
|
|
"""Get Authorize.net API credentials from config."""
|
|
config = load_config()
|
|
return config.AUTH_NET_API_LOGIN_ID, config.AUTH_NET_TRANSACTION_KEY
|
|
|
|
|
|
def get_authnet_url() -> str:
|
|
"""Get the appropriate Authorize.net URL based on environment."""
|
|
config = load_config()
|
|
# Use sandbox for development, production for prod
|
|
if hasattr(config, 'CURRENT_SETTINGS') and config.CURRENT_SETTINGS == 'PRODUCTION':
|
|
return AUTHNET_PRODUCTION_URL
|
|
return AUTHNET_SANDBOX_URL
|
|
|
|
|
|
def validate_payment_credentials():
|
|
"""Validate that payment credentials are configured."""
|
|
config = load_config()
|
|
config.validate_payment_config()
|
|
|
|
|
|
def get_merchant_auth() -> Dict[str, str]:
|
|
"""Get merchantAuthentication block for API requests."""
|
|
api_login_id, transaction_key = get_auth_net_credentials()
|
|
return {
|
|
"name": api_login_id,
|
|
"transactionKey": transaction_key
|
|
}
|
|
|
|
|
|
def make_auth_net_request(request_type: str, request_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""
|
|
Make a request to Authorize.net API.
|
|
|
|
Args:
|
|
request_type: The API request type (e.g., 'createCustomerProfileRequest')
|
|
request_data: The request-specific data (merchantAuthentication will be added)
|
|
|
|
Returns:
|
|
The API response as a dictionary
|
|
|
|
Raises:
|
|
HTTPException: If the API returns an error
|
|
"""
|
|
# Validate credentials are configured
|
|
try:
|
|
validate_payment_credentials()
|
|
except ValueError as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
|
|
url = get_authnet_url()
|
|
|
|
# Build the full request payload with merchant authentication
|
|
payload = {
|
|
request_type: {
|
|
"merchantAuthentication": get_merchant_auth(),
|
|
**request_data
|
|
}
|
|
}
|
|
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
try:
|
|
response = requests.post(url, json=payload, headers=headers, timeout=30)
|
|
response.raise_for_status()
|
|
except requests.exceptions.Timeout:
|
|
logger.error("Authorize.net API timeout")
|
|
raise HTTPException(status_code=504, detail="Payment gateway timeout")
|
|
except requests.exceptions.RequestException as e:
|
|
logger.error(f"Authorize.net API request failed: {e}")
|
|
raise HTTPException(status_code=500, detail="Payment gateway error")
|
|
|
|
# Parse response - Authorize.net returns JSON with a BOM sometimes
|
|
response_text = response.text.lstrip('\ufeff')
|
|
try:
|
|
result = requests.compat.json.loads(response_text)
|
|
except ValueError:
|
|
logger.error(f"Invalid JSON response from Authorize.net: {response_text[:200]}")
|
|
raise HTTPException(status_code=500, detail="Invalid payment gateway response")
|
|
|
|
# Check for API-level errors
|
|
messages = result.get("messages", {})
|
|
if messages.get("resultCode") == "Error":
|
|
error_messages = messages.get("message", [])
|
|
if error_messages:
|
|
error_code = error_messages[0].get("code", "Unknown")
|
|
error_text = error_messages[0].get("text", "Unknown error")
|
|
logger.error(f"Authorize.net error {error_code}: {error_text}")
|
|
raise HTTPException(status_code=400, detail=f"Payment error: {error_text}")
|
|
raise HTTPException(status_code=400, detail="Payment processing failed")
|
|
|
|
return result
|
|
|
|
|
|
def build_bill_to_from_customer(customer: Customer_Customer) -> Dict[str, str]:
|
|
"""
|
|
Build the billTo object from customer data for Authorize.net API requests.
|
|
|
|
Args:
|
|
customer: The customer record with address information
|
|
|
|
Returns:
|
|
Dictionary with billing address fields for Authorize.net
|
|
"""
|
|
# Map state integer to abbreviation
|
|
state_abbrev = STATE_MAPPING.get(customer.customer_state, "")
|
|
|
|
return {
|
|
"firstName": customer.customer_first_name or "",
|
|
"lastName": customer.customer_last_name or "",
|
|
"company": "", # No company field in customer model currently
|
|
"address": customer.customer_address or "",
|
|
"city": customer.customer_town or "",
|
|
"state": state_abbrev,
|
|
"zip": customer.customer_zip or "",
|
|
"country": "USA",
|
|
"phoneNumber": customer.customer_phone_number or ""
|
|
}
|
|
|
|
|
|
async def get_or_create_customer_profile(
|
|
db: AsyncSession,
|
|
customer: Customer_Customer
|
|
) -> str:
|
|
"""
|
|
Get existing customer profile ID or create a new one in Authorize.net CIM.
|
|
|
|
Args:
|
|
db: Database session
|
|
customer: The customer record
|
|
|
|
Returns:
|
|
The Authorize.net customer profile ID
|
|
"""
|
|
# Return existing profile if we have one
|
|
if customer.auth_net_profile_id:
|
|
return customer.auth_net_profile_id
|
|
|
|
# Build email - use customer email or generate a placeholder
|
|
email = customer.customer_email or f"no-email-{customer.id}@example.com"
|
|
|
|
# Create new customer profile with full address information
|
|
# Element order matters for Authorize.net XML schema:
|
|
# merchantCustomerId, description, email, paymentProfiles, shipToList, profileType
|
|
request_data = {
|
|
"profile": {
|
|
"merchantCustomerId": str(customer.id),
|
|
"description": f"{customer.customer_first_name} {customer.customer_last_name}".strip(),
|
|
"email": email,
|
|
"paymentProfiles": [],
|
|
"shipToList": [
|
|
{
|
|
"firstName": customer.customer_first_name or "",
|
|
"lastName": customer.customer_last_name or "",
|
|
"address": customer.customer_address or "",
|
|
"city": customer.customer_town or "",
|
|
"state": STATE_MAPPING.get(customer.customer_state, ""),
|
|
"zip": customer.customer_zip or "",
|
|
"country": "USA",
|
|
"phoneNumber": customer.customer_phone_number or ""
|
|
}
|
|
]
|
|
}
|
|
}
|
|
|
|
response = make_auth_net_request("createCustomerProfileRequest", request_data)
|
|
|
|
customer_profile_id = response.get("customerProfileId")
|
|
if not customer_profile_id:
|
|
raise HTTPException(status_code=500, detail="Failed to create customer profile")
|
|
|
|
# Save the profile ID to the customer record
|
|
customer.auth_net_profile_id = customer_profile_id
|
|
await db.commit()
|
|
|
|
logger.info(f"Created Authorize.net customer profile {customer_profile_id} for customer {customer.id}")
|
|
return customer_profile_id
|
|
|
|
|
|
async def create_payment_profile(
|
|
db: AsyncSession,
|
|
customer_profile_id: str,
|
|
card_details: CardCreate,
|
|
customer: Customer_Customer,
|
|
) -> Card:
|
|
"""
|
|
Create a payment profile (tokenize card) in Authorize.net CIM.
|
|
|
|
Args:
|
|
db: Database session
|
|
customer_profile_id: The Authorize.net customer profile ID
|
|
card_details: The card information
|
|
customer: The customer record
|
|
|
|
Returns:
|
|
The saved Card record with the payment profile ID
|
|
"""
|
|
# Build the billTo from customer data, with fallback to card name
|
|
bill_to = build_bill_to_from_customer(customer)
|
|
|
|
# Override firstName/lastName from card if name_on_card is provided
|
|
if card_details.name_on_card:
|
|
name_parts = card_details.name_on_card.split()
|
|
bill_to["firstName"] = name_parts[0] if name_parts else bill_to["firstName"]
|
|
bill_to["lastName"] = " ".join(name_parts[1:]) if len(name_parts) > 1 else bill_to["lastName"]
|
|
|
|
# Override zip from card details if provided (card billing zip may differ from service address)
|
|
if card_details.zip_code:
|
|
bill_to["zip"] = card_details.zip_code
|
|
|
|
# Build the payment profile request
|
|
request_data = {
|
|
"customerProfileId": customer_profile_id,
|
|
"paymentProfile": {
|
|
"billTo": bill_to,
|
|
"payment": {
|
|
"creditCard": {
|
|
"cardNumber": card_details.card_number,
|
|
"expirationDate": f"{card_details.expiration_year}-{card_details.expiration_month.zfill(2)}",
|
|
"cardCode": card_details.security_number
|
|
}
|
|
}
|
|
},
|
|
"validationMode": "testMode" # Use "liveMode" in production for $0 auth validation
|
|
}
|
|
|
|
response = make_auth_net_request("createCustomerPaymentProfileRequest", request_data)
|
|
|
|
payment_profile_id = response.get("customerPaymentProfileId")
|
|
if not payment_profile_id:
|
|
raise HTTPException(status_code=500, detail="Failed to create payment profile")
|
|
|
|
# Extract last four digits
|
|
digits = ''.join(filter(str.isdigit, card_details.card_number))
|
|
if len(digits) < 4:
|
|
raise HTTPException(status_code=400, detail="Invalid card number")
|
|
last_four = int(digits[-4:])
|
|
|
|
# Determine card type from first digit
|
|
card_type = None
|
|
if digits.startswith('4'):
|
|
card_type = 'Visa'
|
|
elif digits.startswith(('51', '52', '53', '54', '55')) or digits.startswith(('22', '23', '24', '25', '26', '27')):
|
|
card_type = 'Mastercard'
|
|
elif digits.startswith(('34', '37')):
|
|
card_type = 'American Express'
|
|
elif digits.startswith('6011') or digits.startswith('65'):
|
|
card_type = 'Discover'
|
|
|
|
# Save the card record
|
|
saved_card = Card(
|
|
user_id=customer.id,
|
|
auth_net_payment_profile_id=payment_profile_id,
|
|
last_four_digits=last_four,
|
|
name_on_card=card_details.name_on_card,
|
|
expiration_month=card_details.expiration_month,
|
|
expiration_year=card_details.expiration_year,
|
|
zip_code=card_details.zip_code,
|
|
type_of_card=card_type,
|
|
accepted_or_declined=1,
|
|
)
|
|
db.add(saved_card)
|
|
await db.commit()
|
|
await db.refresh(saved_card)
|
|
|
|
logger.info(f"Created payment profile {payment_profile_id} for customer {customer.id}")
|
|
return saved_card
|
|
|
|
|
|
def update_customer_profile(
|
|
customer_profile_id: str,
|
|
customer: Customer_Customer
|
|
) -> bool:
|
|
"""
|
|
Update the customer profile in Authorize.net with current customer data.
|
|
|
|
Args:
|
|
customer_profile_id: The Authorize.net customer profile ID
|
|
customer: The customer record with updated information
|
|
|
|
Returns:
|
|
True if update was successful, False otherwise
|
|
"""
|
|
try:
|
|
email = customer.customer_email or f"no-email-{customer.id}@example.com"
|
|
|
|
# Element order matters for Authorize.net XML schema
|
|
# For updateCustomerProfileRequest, only these fields can be updated
|
|
request_data = {
|
|
"profile": {
|
|
"merchantCustomerId": str(customer.id),
|
|
"description": f"{customer.customer_first_name} {customer.customer_last_name}".strip(),
|
|
"email": email,
|
|
"customerProfileId": customer_profile_id
|
|
}
|
|
}
|
|
|
|
make_auth_net_request("updateCustomerProfileRequest", request_data)
|
|
print(f"[PAYMENT] Updated customer profile {customer_profile_id}")
|
|
return True
|
|
except Exception as e:
|
|
print(f"[PAYMENT] Failed to update customer profile {customer_profile_id}: {e}")
|
|
return False
|
|
|
|
|
|
def update_payment_profile_billing(
|
|
customer_profile_id: str,
|
|
payment_profile_id: str,
|
|
customer: Customer_Customer,
|
|
card: Card
|
|
) -> bool:
|
|
"""
|
|
Update the billing address on an existing payment profile.
|
|
|
|
Args:
|
|
customer_profile_id: The Authorize.net customer profile ID
|
|
payment_profile_id: The Authorize.net payment profile ID
|
|
customer: The customer record with billing information
|
|
card: The card record with last four digits and expiration info
|
|
|
|
Returns:
|
|
True if update was successful, False otherwise
|
|
"""
|
|
try:
|
|
bill_to = build_bill_to_from_customer(customer)
|
|
|
|
# Override zip from card if it has a billing zip
|
|
if card.zip_code:
|
|
bill_to["zip"] = card.zip_code
|
|
|
|
# Zero-pad last four digits (e.g., 2 -> "0002")
|
|
last_four_str = str(card.last_four_digits).zfill(4)
|
|
|
|
print(f"[PAYMENT] Updating payment profile {payment_profile_id} with billTo: {bill_to}")
|
|
|
|
# Element order matters for Authorize.net XML schema:
|
|
# paymentProfile must contain: billTo, payment, customerPaymentProfileId (in that order)
|
|
request_data = {
|
|
"customerProfileId": customer_profile_id,
|
|
"paymentProfile": {
|
|
"billTo": bill_to,
|
|
"payment": {
|
|
"creditCard": {
|
|
"cardNumber": f"XXXX{last_four_str}",
|
|
"expirationDate": f"{card.expiration_year}-{card.expiration_month.zfill(2)}"
|
|
}
|
|
},
|
|
"customerPaymentProfileId": payment_profile_id
|
|
}
|
|
}
|
|
|
|
make_auth_net_request("updateCustomerPaymentProfileRequest", request_data)
|
|
print(f"[PAYMENT] Updated billing info for payment profile {payment_profile_id}")
|
|
return True
|
|
except Exception as e:
|
|
print(f"[PAYMENT] Failed to update billing info for payment profile {payment_profile_id}: {e}")
|
|
return False
|
|
|
|
|
|
def charge_customer_profile(
|
|
customer_profile_id: str,
|
|
payment_profile_id: str,
|
|
amount: Decimal,
|
|
customer: Optional[Customer_Customer] = None,
|
|
order_description: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Charge a saved card using customer and payment profile IDs.
|
|
|
|
Args:
|
|
customer_profile_id: The Authorize.net customer profile ID
|
|
payment_profile_id: The Authorize.net payment profile ID
|
|
amount: The amount to charge
|
|
customer: Optional customer record for billing information
|
|
order_description: Optional order description
|
|
|
|
Returns:
|
|
The transaction response
|
|
"""
|
|
request_data = {
|
|
"transactionRequest": {
|
|
"transactionType": "authOnlyTransaction",
|
|
"amount": str(amount),
|
|
"profile": {
|
|
"customerProfileId": customer_profile_id,
|
|
"paymentProfile": {
|
|
"paymentProfileId": payment_profile_id
|
|
}
|
|
},
|
|
"tax": {
|
|
"amount": "0",
|
|
"name": "Tax Exempt",
|
|
"description": "Charity organization - tax exempt"
|
|
},
|
|
"taxExempt": "true"
|
|
}
|
|
}
|
|
|
|
# Add order description
|
|
if order_description:
|
|
request_data["transactionRequest"]["order"] = {
|
|
"description": order_description[:255]
|
|
}
|
|
|
|
# Add customer info for transaction records (billTo cannot be sent with payment profile -
|
|
# billing address is already stored in the payment profile itself)
|
|
if customer:
|
|
request_data["transactionRequest"]["customer"] = {
|
|
"id": str(customer.id),
|
|
"email": customer.customer_email or f"no-email-{customer.id}@example.com"
|
|
}
|
|
|
|
return make_auth_net_request("createTransactionRequest", request_data)
|
|
|
|
|
|
def charge_card_direct(
|
|
card_details: CardCreate,
|
|
amount: Decimal,
|
|
customer: Optional[Customer_Customer] = None,
|
|
order_description: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Charge a card directly without saving it.
|
|
|
|
Args:
|
|
card_details: The card information
|
|
amount: The amount to charge
|
|
customer: Optional customer record for billing information
|
|
order_description: Optional order description
|
|
|
|
Returns:
|
|
The transaction response
|
|
"""
|
|
request_data = {
|
|
"transactionRequest": {
|
|
"transactionType": "authOnlyTransaction",
|
|
"amount": str(amount),
|
|
"payment": {
|
|
"creditCard": {
|
|
"cardNumber": card_details.card_number,
|
|
"expirationDate": f"{card_details.expiration_year}-{card_details.expiration_month.zfill(2)}",
|
|
"cardCode": card_details.security_number
|
|
}
|
|
},
|
|
"tax": {
|
|
"amount": "0",
|
|
"name": "Tax Exempt",
|
|
"description": "Charity organization - tax exempt"
|
|
},
|
|
"taxExempt": "true"
|
|
}
|
|
}
|
|
|
|
# Add order description
|
|
if order_description:
|
|
request_data["transactionRequest"]["order"] = {
|
|
"description": order_description[:255]
|
|
}
|
|
|
|
# Add customer and billing information (customer must come before billTo in schema)
|
|
if customer:
|
|
request_data["transactionRequest"]["customer"] = {
|
|
"id": str(customer.id),
|
|
"email": customer.customer_email or f"no-email-{customer.id}@example.com"
|
|
}
|
|
bill_to = build_bill_to_from_customer(customer)
|
|
# Override firstName/lastName from card if name_on_card is provided
|
|
if card_details.name_on_card:
|
|
name_parts = card_details.name_on_card.split()
|
|
bill_to["firstName"] = name_parts[0] if name_parts else bill_to["firstName"]
|
|
bill_to["lastName"] = " ".join(name_parts[1:]) if len(name_parts) > 1 else bill_to["lastName"]
|
|
# Override zip from card details if provided
|
|
if card_details.zip_code:
|
|
bill_to["zip"] = card_details.zip_code
|
|
request_data["transactionRequest"]["billTo"] = bill_to
|
|
|
|
return make_auth_net_request("createTransactionRequest", request_data)
|
|
|
|
@router.post("/process", response_model=PaymentResponse)
|
|
async def process_payment(
|
|
payment_data: PaymentCreate,
|
|
current_user: Account_User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Process a payment using Authorize.net.
|
|
|
|
Supports two modes:
|
|
1. New card: Provide card_details. Optionally set save_card=True to tokenize.
|
|
2. Saved card: Provide card_id to charge an existing saved card.
|
|
"""
|
|
if not current_user.user_id:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
# Get customer info
|
|
customer_result = await db.execute(
|
|
select(Customer_Customer).where(Customer_Customer.id == current_user.user_id)
|
|
)
|
|
customer = customer_result.scalar_one_or_none()
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
auth_net_transaction_id = None
|
|
saved_card = None
|
|
card_id_used = payment_data.card_id
|
|
|
|
try:
|
|
if payment_data.card_id:
|
|
# Charge a saved card using CIM profile
|
|
card_result = await db.execute(
|
|
select(Card).where(
|
|
Card.id == payment_data.card_id,
|
|
Card.user_id == current_user.user_id
|
|
)
|
|
)
|
|
saved_card = card_result.scalar_one_or_none()
|
|
if not saved_card:
|
|
raise HTTPException(status_code=404, detail="Saved card not found")
|
|
|
|
if not saved_card.auth_net_payment_profile_id:
|
|
raise HTTPException(status_code=400, detail="Card is not properly tokenized")
|
|
|
|
if not customer.auth_net_profile_id:
|
|
raise HTTPException(status_code=400, detail="Customer profile not found")
|
|
|
|
# Log customer data for debugging
|
|
print(f"[PAYMENT] Processing payment for customer {customer.id}: "
|
|
f"name={customer.customer_first_name} {customer.customer_last_name}, "
|
|
f"address={customer.customer_address}, town={customer.customer_town}, "
|
|
f"state={customer.customer_state}, zip={customer.customer_zip}, "
|
|
f"phone={customer.customer_phone_number}")
|
|
|
|
# Update customer profile with current customer data
|
|
update_customer_profile(customer.auth_net_profile_id, customer)
|
|
|
|
# Update billing info on the payment profile (ensures existing cards have current address)
|
|
update_payment_profile_billing(
|
|
customer_profile_id=customer.auth_net_profile_id,
|
|
payment_profile_id=saved_card.auth_net_payment_profile_id,
|
|
customer=customer,
|
|
card=saved_card
|
|
)
|
|
|
|
# Charge using the saved profile
|
|
response = charge_customer_profile(
|
|
customer_profile_id=customer.auth_net_profile_id,
|
|
payment_profile_id=saved_card.auth_net_payment_profile_id,
|
|
amount=payment_data.amount,
|
|
customer=customer,
|
|
order_description=f"Delivery #{payment_data.delivery_id}" if payment_data.delivery_id else None
|
|
)
|
|
|
|
# Extract transaction ID from response
|
|
trans_response = response.get("transactionResponse", {})
|
|
auth_net_transaction_id = trans_response.get("transId")
|
|
|
|
elif payment_data.card_details:
|
|
# Process with new card details
|
|
if payment_data.card_details.save_card:
|
|
# Create/get customer profile and save the card first
|
|
customer_profile_id = await get_or_create_customer_profile(db, customer)
|
|
|
|
# Create payment profile (tokenize the card)
|
|
saved_card = await create_payment_profile(
|
|
db=db,
|
|
customer_profile_id=customer_profile_id,
|
|
card_details=payment_data.card_details,
|
|
customer=customer,
|
|
)
|
|
card_id_used = saved_card.id
|
|
|
|
# Now charge using the newly created profile
|
|
response = charge_customer_profile(
|
|
customer_profile_id=customer_profile_id,
|
|
payment_profile_id=saved_card.auth_net_payment_profile_id,
|
|
amount=payment_data.amount,
|
|
customer=customer,
|
|
order_description=f"Delivery #{payment_data.delivery_id}" if payment_data.delivery_id else None
|
|
)
|
|
|
|
trans_response = response.get("transactionResponse", {})
|
|
auth_net_transaction_id = trans_response.get("transId")
|
|
|
|
else:
|
|
# One-time charge without saving the card
|
|
response = charge_card_direct(
|
|
card_details=payment_data.card_details,
|
|
amount=payment_data.amount,
|
|
customer=customer,
|
|
order_description=f"Delivery #{payment_data.delivery_id}" if payment_data.delivery_id else None
|
|
)
|
|
|
|
trans_response = response.get("transactionResponse", {})
|
|
auth_net_transaction_id = trans_response.get("transId")
|
|
|
|
else:
|
|
raise HTTPException(status_code=400, detail="Must provide either card_id or card_details")
|
|
|
|
# Verify we got a transaction ID
|
|
if not auth_net_transaction_id:
|
|
raise HTTPException(status_code=500, detail="Transaction failed - no transaction ID returned")
|
|
|
|
# Check transaction response code
|
|
trans_response = response.get("transactionResponse", {})
|
|
response_code = trans_response.get("responseCode")
|
|
|
|
# Response codes: 1 = Approved, 2 = Declined, 3 = Error, 4 = Held for Review
|
|
if response_code == "2":
|
|
# Declined
|
|
errors = trans_response.get("errors", [])
|
|
error_text = errors[0].get("errorText", "Transaction declined") if errors else "Transaction declined"
|
|
raise HTTPException(status_code=400, detail=f"Payment declined: {error_text}")
|
|
elif response_code == "3":
|
|
# Error
|
|
errors = trans_response.get("errors", [])
|
|
error_text = errors[0].get("errorText", "Transaction error") if errors else "Transaction error"
|
|
raise HTTPException(status_code=400, detail=f"Payment error: {error_text}")
|
|
elif response_code == "4":
|
|
# Held for review - still save the transaction but mark status differently
|
|
logger.warning(f"Transaction {auth_net_transaction_id} held for review")
|
|
|
|
# Determine transaction status
|
|
status = 1 if response_code == "1" else (2 if response_code == "4" else 0)
|
|
|
|
# Save transaction record
|
|
transaction_record = Transaction(
|
|
preauthorize_amount=payment_data.amount,
|
|
charge_amount=None,
|
|
customer_id=customer.id,
|
|
transaction_type=1, # pre-authorization
|
|
status=status,
|
|
auth_net_transaction_id=auth_net_transaction_id,
|
|
delivery_id=payment_data.delivery_id,
|
|
card_id=card_id_used,
|
|
payment_gateway=1, # Authorize.net
|
|
)
|
|
db.add(transaction_record)
|
|
await db.commit()
|
|
|
|
return PaymentResponse(
|
|
transaction_id=str(transaction_record.id),
|
|
status="approved" if response_code == "1" else "held_for_review",
|
|
amount=payment_data.amount,
|
|
auth_net_transaction_id=auth_net_transaction_id
|
|
)
|
|
|
|
except HTTPException:
|
|
await db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Payment processing error: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}")
|
|
|
|
@router.get("/cards", response_model=list[CardResponse])
|
|
async def get_saved_cards(
|
|
current_user: Account_User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Get all saved cards for the current customer."""
|
|
if not current_user.user_id:
|
|
return []
|
|
|
|
cards = await db.execute(
|
|
select(Card).where(Card.user_id == current_user.user_id)
|
|
)
|
|
return cards.scalars().all()
|
|
|
|
|
|
@router.post("/cards", response_model=CardResponse)
|
|
async def save_card(
|
|
card_details: CardCreate,
|
|
current_user: Account_User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Save a card without charging it (tokenize only).
|
|
|
|
Creates a customer profile in Authorize.net CIM if one doesn't exist,
|
|
then creates a payment profile for the card.
|
|
"""
|
|
if not current_user.user_id:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
# Get customer info
|
|
customer_result = await db.execute(
|
|
select(Customer_Customer).where(Customer_Customer.id == current_user.user_id)
|
|
)
|
|
customer = customer_result.scalar_one_or_none()
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
try:
|
|
# Create/get customer profile
|
|
customer_profile_id = await get_or_create_customer_profile(db, customer)
|
|
|
|
# Create payment profile (tokenize the card)
|
|
saved_card = await create_payment_profile(
|
|
db=db,
|
|
customer_profile_id=customer_profile_id,
|
|
card_details=card_details,
|
|
customer=customer,
|
|
)
|
|
|
|
return saved_card
|
|
|
|
except HTTPException:
|
|
await db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Error saving card: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Error saving card: {str(e)}")
|
|
|
|
|
|
@router.delete("/cards/{card_id}")
|
|
async def delete_card(
|
|
card_id: int,
|
|
current_user: Account_User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Delete a saved card.
|
|
|
|
Removes the payment profile from Authorize.net CIM and deletes the local record.
|
|
"""
|
|
if not current_user.user_id:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
# Get the card (verify it belongs to this customer)
|
|
card_result = await db.execute(
|
|
select(Card).where(
|
|
Card.id == card_id,
|
|
Card.user_id == current_user.user_id
|
|
)
|
|
)
|
|
card = card_result.scalar_one_or_none()
|
|
if not card:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
|
|
# Get customer for the Authorize.net profile ID
|
|
customer_result = await db.execute(
|
|
select(Customer_Customer).where(Customer_Customer.id == current_user.user_id)
|
|
)
|
|
customer = customer_result.scalar_one_or_none()
|
|
|
|
try:
|
|
# Delete from Authorize.net if we have the profile IDs
|
|
if card.auth_net_payment_profile_id and customer and customer.auth_net_profile_id:
|
|
request_data = {
|
|
"customerProfileId": customer.auth_net_profile_id,
|
|
"customerPaymentProfileId": card.auth_net_payment_profile_id
|
|
}
|
|
|
|
try:
|
|
make_auth_net_request("deleteCustomerPaymentProfileRequest", request_data)
|
|
logger.info(f"Deleted payment profile {card.auth_net_payment_profile_id} from Authorize.net")
|
|
except HTTPException as e:
|
|
# Log but don't fail if Authorize.net deletion fails
|
|
# The profile might already be deleted or invalid
|
|
logger.warning(f"Failed to delete payment profile from Authorize.net: {e.detail}")
|
|
|
|
# Delete the local record
|
|
await db.delete(card)
|
|
await db.commit()
|
|
|
|
return {"message": "Card deleted successfully"}
|
|
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Error deleting card: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Error deleting card: {str(e)}")
|
|
|
|
|
|
@router.post("/cards/{card_id}/set-default")
|
|
async def set_default_card(
|
|
card_id: int,
|
|
current_user: Account_User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""Set a card as the default payment method."""
|
|
if not current_user.user_id:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
# Verify the card belongs to the customer
|
|
card_result = await db.execute(
|
|
select(Card).where(
|
|
Card.id == card_id,
|
|
Card.user_id == current_user.user_id
|
|
)
|
|
)
|
|
card = card_result.scalar_one_or_none()
|
|
if not card:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
|
|
# Unset any existing default for this customer
|
|
all_cards = await db.execute(
|
|
select(Card).where(Card.user_id == current_user.user_id)
|
|
)
|
|
for c in all_cards.scalars():
|
|
c.main_card = False
|
|
|
|
# Set this card as default
|
|
card.main_card = True
|
|
await db.commit()
|
|
|
|
return {"message": "Default card updated successfully"}
|
|
|
|
|
|
@router.put("/cards/{card_id}", response_model=CardResponse)
|
|
async def update_card(
|
|
card_id: int,
|
|
card_update: CardUpdate,
|
|
current_user: Account_User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Update a saved card's details.
|
|
|
|
Updates both the local database and syncs to Authorize.Net CIM.
|
|
Only name_on_card, expiration_month, expiration_year, and zip_code can be updated.
|
|
"""
|
|
if not current_user.user_id:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
# Get the card (verify it belongs to this customer)
|
|
card_result = await db.execute(
|
|
select(Card).where(
|
|
Card.id == card_id,
|
|
Card.user_id == current_user.user_id
|
|
)
|
|
)
|
|
card = card_result.scalar_one_or_none()
|
|
if not card:
|
|
raise HTTPException(status_code=404, detail="Card not found")
|
|
|
|
# Get customer for Authorize.net profile ID
|
|
customer_result = await db.execute(
|
|
select(Customer_Customer).where(Customer_Customer.id == current_user.user_id)
|
|
)
|
|
customer = customer_result.scalar_one_or_none()
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
try:
|
|
# Update Authorize.Net payment profile if we have the profile IDs
|
|
if card.auth_net_payment_profile_id and customer.auth_net_profile_id:
|
|
# Build billTo from customer data
|
|
bill_to = build_bill_to_from_customer(customer)
|
|
|
|
# Override firstName/lastName from card name if provided
|
|
name_on_card = card_update.name_on_card if card_update.name_on_card else card.name_on_card
|
|
if name_on_card:
|
|
name_parts = name_on_card.split()
|
|
bill_to["firstName"] = name_parts[0] if name_parts else bill_to["firstName"]
|
|
bill_to["lastName"] = " ".join(name_parts[1:]) if len(name_parts) > 1 else bill_to["lastName"]
|
|
|
|
exp_month = card_update.expiration_month if card_update.expiration_month else card.expiration_month
|
|
exp_year = card_update.expiration_year if card_update.expiration_year else card.expiration_year
|
|
|
|
# Override zip from card details if provided
|
|
if card_update.zip_code:
|
|
bill_to["zip"] = card_update.zip_code
|
|
elif card.zip_code:
|
|
bill_to["zip"] = card.zip_code
|
|
|
|
# Zero-pad last four digits (e.g., 2 -> "0002")
|
|
last_four_str = str(card.last_four_digits).zfill(4)
|
|
|
|
# Element order matters for Authorize.net XML schema
|
|
request_data = {
|
|
"customerProfileId": customer.auth_net_profile_id,
|
|
"paymentProfile": {
|
|
"billTo": bill_to,
|
|
"payment": {
|
|
"creditCard": {
|
|
"cardNumber": f"XXXX{last_four_str}",
|
|
"expirationDate": f"{exp_year}-{exp_month.zfill(2)}"
|
|
}
|
|
},
|
|
"customerPaymentProfileId": card.auth_net_payment_profile_id
|
|
}
|
|
}
|
|
|
|
try:
|
|
make_auth_net_request("updateCustomerPaymentProfileRequest", request_data)
|
|
print(f"[PAYMENT] Updated payment profile {card.auth_net_payment_profile_id} in Authorize.net")
|
|
except HTTPException as e:
|
|
print(f"[PAYMENT] Failed to update payment profile in Authorize.net: {e.detail}")
|
|
# Continue with local update even if Authorize.net update fails
|
|
|
|
# Update local database fields
|
|
if card_update.name_on_card is not None:
|
|
card.name_on_card = card_update.name_on_card
|
|
if card_update.expiration_month is not None:
|
|
card.expiration_month = card_update.expiration_month
|
|
if card_update.expiration_year is not None:
|
|
card.expiration_year = card_update.expiration_year
|
|
if card_update.zip_code is not None:
|
|
card.zip_code = card_update.zip_code
|
|
|
|
await db.commit()
|
|
await db.refresh(card)
|
|
|
|
return card
|
|
|
|
except HTTPException:
|
|
await db.rollback()
|
|
raise
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"Error updating card: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Error updating card: {str(e)}")
|
|
|
|
|
|
@router.post("/sync-billing-info")
|
|
async def sync_billing_info(
|
|
current_user: Account_User = Depends(get_current_user),
|
|
db: AsyncSession = Depends(get_db)
|
|
):
|
|
"""
|
|
Sync customer billing information to Authorize.net.
|
|
|
|
Updates the customer profile and all payment profiles with current customer data.
|
|
Call this after updating customer address or contact information.
|
|
"""
|
|
if not current_user.user_id:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
# Get customer info
|
|
customer_result = await db.execute(
|
|
select(Customer_Customer).where(Customer_Customer.id == current_user.user_id)
|
|
)
|
|
customer = customer_result.scalar_one_or_none()
|
|
if not customer:
|
|
raise HTTPException(status_code=404, detail="Customer not found")
|
|
|
|
if not customer.auth_net_profile_id:
|
|
return {"message": "No Authorize.net profile to update", "updated_cards": 0}
|
|
|
|
results = {
|
|
"customer_profile_updated": False,
|
|
"cards_updated": 0,
|
|
"cards_failed": 0
|
|
}
|
|
|
|
try:
|
|
# Update customer profile
|
|
if update_customer_profile(customer.auth_net_profile_id, customer):
|
|
results["customer_profile_updated"] = True
|
|
|
|
# Get all saved cards for the customer
|
|
cards_result = await db.execute(
|
|
select(Card).where(Card.user_id == current_user.user_id)
|
|
)
|
|
cards = cards_result.scalars().all()
|
|
|
|
# Update billing info on each payment profile
|
|
for card in cards:
|
|
if card.auth_net_payment_profile_id:
|
|
if update_payment_profile_billing(
|
|
customer_profile_id=customer.auth_net_profile_id,
|
|
payment_profile_id=card.auth_net_payment_profile_id,
|
|
customer=customer,
|
|
card=card
|
|
):
|
|
results["cards_updated"] += 1
|
|
else:
|
|
results["cards_failed"] += 1
|
|
|
|
return {
|
|
"message": "Billing info sync completed",
|
|
**results
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error syncing billing info: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Error syncing billing info: {str(e)}")
|