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,