Square Payments

This commit is contained in:
James Pattinson
2025-11-12 16:09:38 +00:00
parent be2426c078
commit 0f74333a22
19 changed files with 1828 additions and 85 deletions

View File

@@ -1,19 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from ...core.database import get_db
from ...models.models import Payment, PaymentStatus, User, Membership, MembershipStatus
from ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier
from ...schemas import (
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse,
SquarePaymentRequest, SquarePaymentResponse, SquareRefundRequest
)
from ...api.dependencies import get_current_active_user, get_admin_user
from ...services.email_service import email_service
from ...services.square_service import square_service
from ...core.config import settings
router = APIRouter()
@router.get("/config/square")
async def get_square_config():
"""Get Square configuration for frontend"""
return {
"application_id": settings.SQUARE_APPLICATION_ID,
"location_id": settings.SQUARE_LOCATION_ID,
"environment": settings.SQUARE_ENVIRONMENT
}
@router.get("/my-payments", response_model=List[PaymentResponse])
async def get_my_payments(
current_user: User = Depends(get_current_active_user),
@@ -144,6 +158,181 @@ async def update_payment(
return payment
@router.post("/square/process", response_model=SquarePaymentResponse, status_code=status.HTTP_201_CREATED)
async def process_square_payment(
payment_request: SquarePaymentRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""
Process a Square payment and create membership
This endpoint receives a payment token from the Square Web Payments SDK,
processes the payment through Square's API, and creates an ACTIVE membership
"""
# Verify tier exists
tier = db.query(MembershipTier).filter(
MembershipTier.id == payment_request.tier_id,
MembershipTier.is_active == True
).first()
if not tier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership tier not found or not active"
)
# Create a reference ID for tracking
reference_id = f"user_{current_user.id}_tier_{tier.id}_{datetime.utcnow().timestamp()}"
# Process payment with Square
square_result = await square_service.create_payment(
amount_money=payment_request.amount,
source_id=payment_request.source_id,
idempotency_key=payment_request.idempotency_key,
reference_id=reference_id,
note=payment_request.note or f"Membership payment for {tier.name} - {current_user.email}"
)
if not square_result.get('success'):
# Payment failed - don't create membership
return SquarePaymentResponse(
success=False,
errors=square_result.get('errors', ['Payment processing failed'])
)
# Payment succeeded - create membership and payment records in a transaction
try:
# Calculate membership dates
start_date = datetime.utcnow().date()
end_date = start_date + relativedelta(years=1)
# Create membership with ACTIVE status
membership = Membership(
user_id=current_user.id,
tier_id=tier.id,
start_date=start_date,
end_date=end_date,
status=MembershipStatus.ACTIVE
)
db.add(membership)
db.flush() # Get membership ID without committing
# Create payment record
payment = Payment(
user_id=current_user.id,
membership_id=membership.id,
amount=payment_request.amount,
payment_method=PaymentMethod.SQUARE,
status=PaymentStatus.COMPLETED,
transaction_id=square_result.get('payment_id'),
payment_date=datetime.utcnow(),
notes=payment_request.note
)
db.add(payment)
# Commit both together
db.commit()
db.refresh(membership)
db.refresh(payment)
# Send activation email (non-blocking)
try:
await email_service.send_membership_activation_email(
to_email=current_user.email,
first_name=current_user.first_name,
membership_tier=tier.name,
annual_fee=tier.annual_fee,
payment_amount=payment.amount,
payment_method="Square",
renewal_date=end_date.strftime("%d %B %Y"),
db=db
)
except Exception as e:
# Log error but don't fail the payment
print(f"Failed to send membership activation email: {e}")
return SquarePaymentResponse(
success=True,
payment_id=square_result.get('payment_id'),
status=square_result.get('status'),
amount=payment_request.amount,
currency='GBP',
receipt_url=square_result.get('receipt_url'),
database_payment_id=payment.id,
membership_id=membership.id
)
except Exception as e:
db.rollback()
# Payment succeeded but membership creation failed
# This is a critical error - we should log it and potentially refund
print(f"CRITICAL: Payment succeeded but membership creation failed: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Payment processed but membership creation failed. Please contact support."
)
@router.post("/square/refund", response_model=MessageResponse)
async def refund_square_payment(
refund_request: SquareRefundRequest,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""
Refund a Square payment (admin only)
"""
# Get the payment from database
payment = db.query(Payment).filter(Payment.id == refund_request.payment_id).first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
if payment.payment_method != PaymentMethod.SQUARE:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only refund Square payments through this endpoint"
)
if payment.status != PaymentStatus.COMPLETED:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Can only refund completed payments"
)
if not payment.transaction_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Payment has no Square transaction ID"
)
# Process refund with Square
refund_result = await square_service.refund_payment(
payment_id=payment.transaction_id,
amount_money=refund_request.amount,
reason=refund_request.reason
)
if not refund_result.get('success'):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Refund failed: {', '.join(refund_result.get('errors', ['Unknown error']))}"
)
# Update payment status
payment.status = PaymentStatus.REFUNDED
payment.notes = f"{payment.notes or ''}\nRefunded: {refund_request.reason or 'No reason provided'}"
db.commit()
return MessageResponse(
message="Payment refunded successfully",
detail=f"Refund ID: {refund_result.get('refund_id')}"
)
@router.get("/", response_model=List[PaymentResponse])
async def list_payments(
skip: int = 0,

View File

@@ -31,6 +31,7 @@ class Settings(BaseSettings):
SQUARE_ACCESS_TOKEN: str
SQUARE_ENVIRONMENT: str = "sandbox"
SQUARE_LOCATION_ID: str
SQUARE_APPLICATION_ID: str
# Email
SMTP2GO_API_KEY: str

View File

@@ -22,6 +22,9 @@ from .schemas import (
PaymentCreate,
PaymentUpdate,
PaymentResponse,
SquarePaymentRequest,
SquarePaymentResponse,
SquareRefundRequest,
MessageResponse,
EmailTemplateBase,
EmailTemplateCreate,
@@ -53,6 +56,9 @@ __all__ = [
"PaymentCreate",
"PaymentUpdate",
"PaymentResponse",
"SquarePaymentRequest",
"SquarePaymentResponse",
"SquareRefundRequest",
"MessageResponse",
"EmailTemplateBase",
"EmailTemplateCreate",

View File

@@ -163,6 +163,36 @@ class PaymentResponse(BaseModel):
created_at: datetime
# Square Payment Schemas
class SquarePaymentRequest(BaseModel):
"""Request schema for Square payment processing"""
source_id: str = Field(..., description="Payment source ID from Square Web Payments SDK")
tier_id: int = Field(..., description="Membership tier ID to create membership for")
amount: float = Field(..., gt=0, description="Payment amount in GBP")
idempotency_key: Optional[str] = Field(None, description="Unique key to prevent duplicate payments")
note: Optional[str] = Field(None, description="Optional payment note")
class SquarePaymentResponse(BaseModel):
"""Response schema for Square payment"""
success: bool
payment_id: Optional[str] = None
status: Optional[str] = None
amount: Optional[float] = None
currency: Optional[str] = None
receipt_url: Optional[str] = None
errors: Optional[list[str]] = None
database_payment_id: Optional[int] = None
membership_id: Optional[int] = Field(None, description="Created membership ID")
class SquareRefundRequest(BaseModel):
"""Request schema for Square payment refund"""
payment_id: int = Field(..., description="Database payment ID")
amount: Optional[float] = Field(None, gt=0, description="Amount to refund (None for full refund)")
reason: Optional[str] = Field(None, description="Reason for refund")
# Message Response
class MessageResponse(BaseModel):
message: str

View File

@@ -0,0 +1,332 @@
"""
Square Payment Service
Handles Square payment processing including payment creation, verification, and webhooks
"""
from square.client import Square, SquareEnvironment
from typing import Dict, Optional
import uuid
from datetime import datetime
from ..core.config import settings
class SquareService:
"""Service for handling Square payment processing"""
def __init__(self):
"""Initialize Square client"""
# Configure Square client based on environment
environment = (
SquareEnvironment.SANDBOX
if settings.SQUARE_ENVIRONMENT.lower() == 'sandbox'
else SquareEnvironment.PRODUCTION
)
self.client = Square(
token=settings.SQUARE_ACCESS_TOKEN,
environment=environment
)
self.location_id = settings.SQUARE_LOCATION_ID
async def create_payment(
self,
amount_money: float,
source_id: str,
idempotency_key: Optional[str] = None,
customer_id: Optional[str] = None,
reference_id: Optional[str] = None,
note: Optional[str] = None
) -> Dict:
"""
Create a payment using Square
Args:
amount_money: Amount in the currency's smallest denomination (e.g., cents for GBP)
source_id: Payment source ID from the Web Payments SDK
idempotency_key: Unique key to prevent duplicate payments
customer_id: Optional Square customer ID
reference_id: Optional reference ID for internal tracking
note: Optional note about the payment
Returns:
Dict with payment result including payment_id, status, and details
"""
try:
# Generate idempotency key if not provided
if not idempotency_key:
idempotency_key = str(uuid.uuid4())
# Convert amount to money object (Square expects amount in smallest currency unit)
# For GBP, this is pence
amount_in_pence = int(amount_money * 100)
# Create payment - pass parameters directly as keyword arguments
result = self.client.payments.create(
source_id=source_id,
idempotency_key=idempotency_key,
amount_money={
'amount': amount_in_pence,
'currency': 'GBP'
},
location_id=self.location_id,
customer_id=customer_id if customer_id else None,
reference_id=reference_id if reference_id else None,
note=note if note else None
)
if result.errors:
# Payment failed - extract user-friendly error messages
error_messages = []
for error in result.errors:
code = error.code if hasattr(error, 'code') else None
detail = error.detail if hasattr(error, 'detail') else str(error)
# Map Square error codes to user-friendly messages
if code == 'GENERIC_DECLINE':
error_messages.append('Your card was declined. Please try a different payment method.')
elif code == 'CVV_FAILURE':
error_messages.append('The CVV code is invalid. Please check and try again.')
elif code == 'INVALID_CARD':
error_messages.append('The card information is invalid. Please check your card details.')
elif code == 'CARD_DECLINED':
error_messages.append('Your card was declined. Please contact your bank or try a different card.')
elif code == 'INSUFFICIENT_FUNDS':
error_messages.append('Insufficient funds. Please try a different payment method.')
elif code == 'INVALID_EXPIRATION':
error_messages.append('The card expiration date is invalid.')
elif code == 'ADDRESS_VERIFICATION_FAILURE':
error_messages.append('Address verification failed. Please check your billing address.')
else:
# For other errors, use the detail message but clean it up
if 'Authorization error' in detail:
error_messages.append('Payment authorization failed. Please try a different card.')
else:
error_messages.append(detail)
return {
'success': False,
'errors': error_messages if error_messages else ['Payment processing failed. Please try again.']
}
# Payment succeeded
payment = result.payment
return {
'success': True,
'payment_id': payment.id,
'status': payment.status,
'amount': amount_money,
'currency': 'GBP',
'created_at': payment.created_at,
'receipt_url': payment.receipt_url if hasattr(payment, 'receipt_url') else None,
'reference_id': reference_id
}
except Exception as e:
# Handle Square API exceptions
error_message = str(e)
# Check if this is a Square API error with response body
if hasattr(e, 'errors') and e.errors:
# Extract user-friendly messages from Square error
friendly_errors = []
for error in e.errors:
code = error.get('code') if isinstance(error, dict) else getattr(error, 'code', None)
if code == 'GENERIC_DECLINE':
friendly_errors.append('Your card was declined. Please try a different payment method.')
elif code == 'INSUFFICIENT_FUNDS':
friendly_errors.append('Insufficient funds. Please try a different payment method.')
elif code == 'CVV_FAILURE':
friendly_errors.append('The CVV code is invalid. Please check and try again.')
elif code == 'CARD_DECLINED':
friendly_errors.append('Your card was declined. Please contact your bank or try a different card.')
else:
detail = error.get('detail') if isinstance(error, dict) else getattr(error, 'detail', str(error))
if 'Authorization error' in str(detail):
friendly_errors.append('Payment authorization failed. Please try a different card.')
else:
friendly_errors.append(str(detail))
return {
'success': False,
'errors': friendly_errors if friendly_errors else ['Payment processing failed. Please try again.']
}
# Generic error fallback
return {
'success': False,
'errors': ['Payment processing failed. Please try again or contact support.']
}
async def get_payment(self, payment_id: str) -> Dict:
"""
Retrieve payment details from Square
Args:
payment_id: Square payment ID
Returns:
Dict with payment details
"""
try:
result = self.client.payments.get(payment_id)
if result.errors:
return {
'success': False,
'errors': [error.detail if hasattr(error, 'detail') else str(error) for error in result.errors]
}
payment = result.payment
amount_money = payment.amount_money
return {
'success': True,
'payment_id': payment.id,
'status': payment.status,
'amount': amount_money.amount / 100, # Convert pence to pounds
'currency': amount_money.currency,
'created_at': payment.created_at,
'receipt_url': payment.receipt_url if hasattr(payment, 'receipt_url') else None,
'reference_id': payment.reference_id if hasattr(payment, 'reference_id') else None
}
except Exception as e:
return {
'success': False,
'errors': [str(e)]
}
async def refund_payment(
self,
payment_id: str,
amount_money: Optional[float] = None,
reason: Optional[str] = None,
idempotency_key: Optional[str] = None
) -> Dict:
"""
Refund a payment
Args:
payment_id: Square payment ID to refund
amount_money: Amount to refund (None for full refund)
reason: Reason for the refund
idempotency_key: Unique key to prevent duplicate refunds
Returns:
Dict with refund result
"""
try:
if not idempotency_key:
idempotency_key = str(uuid.uuid4())
# Prepare parameters
amount_in_pence = int(amount_money * 100) if amount_money else None
# Create refund - pass parameters directly as keyword arguments
if amount_in_pence:
result = self.client.refunds.refund_payment(
idempotency_key=idempotency_key,
amount_money={
'amount': amount_in_pence,
'currency': 'GBP'
},
payment_id=payment_id,
reason=reason if reason else None
)
else:
# Full refund - get payment amount first
payment_result = await self.get_payment(payment_id)
if not payment_result.get('success'):
return payment_result
amount_in_pence = int(payment_result['amount'] * 100)
result = self.client.refunds.refund_payment(
idempotency_key=idempotency_key,
amount_money={
'amount': amount_in_pence,
'currency': 'GBP'
},
payment_id=payment_id,
reason=reason if reason else None
)
if result.errors:
return {
'success': False,
'errors': [error.detail if hasattr(error, 'detail') else str(error) for error in result.errors]
}
refund = result.refund
return {
'success': True,
'refund_id': refund.id,
'status': refund.status,
'payment_id': payment_id
}
except Exception as e:
return {
'success': False,
'errors': [str(e)]
}
async def create_customer(
self,
email: str,
given_name: str,
family_name: str,
phone_number: Optional[str] = None,
address: Optional[Dict] = None,
idempotency_key: Optional[str] = None
) -> Dict:
"""
Create a Square customer for future payments
Args:
email: Customer email
given_name: Customer first name
family_name: Customer last name
phone_number: Optional phone number
address: Optional address dict
idempotency_key: Unique key
Returns:
Dict with customer details
"""
try:
if not idempotency_key:
idempotency_key = str(uuid.uuid4())
# Create customer - pass parameters directly as keyword arguments
result = self.client.customers.create(
idempotency_key=idempotency_key,
email_address=email,
given_name=given_name,
family_name=family_name,
phone_number=phone_number if phone_number else None,
address=address if address else None
)
if result.errors:
return {
'success': False,
'errors': [error.detail if hasattr(error, 'detail') else str(error) for error in result.errors]
}
customer = result.customer
return {
'success': True,
'customer_id': customer.id,
'email': customer.email_address if hasattr(customer, 'email_address') else None
}
except Exception as e:
return {
'success': False,
'errors': [str(e)]
}
# Singleton instance
square_service = SquareService()