Files
api/routes/payment/routes.py
2026-01-17 15:21:41 -05:00

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)}")