Files
sasa-membership/backend/app/api/v1/payments.py
James Pattinson 0f74333a22 Square Payments
2025-11-12 16:09:38 +00:00

420 lines
14 KiB
Python

from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from ...core.database import get_db
from ...models.models import Payment, PaymentStatus, PaymentMethod, User, Membership, MembershipStatus, MembershipTier
from ...schemas import (
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),
db: Session = Depends(get_db)
):
"""Get current user's payment history"""
payments = db.query(Payment).filter(
Payment.user_id == current_user.id
).order_by(Payment.created_at.desc()).all()
return payments
@router.post("/", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
async def create_payment(
payment_data: PaymentCreate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Create a new payment"""
# Verify membership exists if provided
if payment_data.membership_id:
membership = db.query(Membership).filter(
Membership.id == payment_data.membership_id,
Membership.user_id == current_user.id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership not found or does not belong to user"
)
payment = Payment(
user_id=current_user.id,
membership_id=payment_data.membership_id,
amount=payment_data.amount,
payment_method=payment_data.payment_method,
notes=payment_data.notes,
status=PaymentStatus.PENDING
)
db.add(payment)
db.commit()
db.refresh(payment)
return payment
@router.get("/{payment_id}", response_model=PaymentResponse)
async def get_payment(
payment_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get payment by ID"""
payment = db.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
# Check if user has permission to view this payment
if payment.user_id != current_user.id and current_user.role.value not in ["admin", "super_admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view this payment"
)
return payment
@router.put("/{payment_id}", response_model=PaymentResponse)
async def update_payment(
payment_id: int,
payment_update: PaymentUpdate,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Update payment (admin only)"""
payment = db.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
update_data = payment_update.model_dump(exclude_unset=True)
# If marking as completed, set payment_date if not already set
if update_data.get("status") == PaymentStatus.COMPLETED and not payment.payment_date:
update_data["payment_date"] = datetime.utcnow()
for field, value in update_data.items():
setattr(payment, field, value)
db.commit()
db.refresh(payment)
# If payment was just marked as completed and has an associated membership,
# activate the membership and send activation email
if (update_data.get("status") == PaymentStatus.COMPLETED and
payment.membership_id and
payment.membership.status == MembershipStatus.PENDING):
# Activate the membership
payment.membership.status = MembershipStatus.ACTIVE
db.commit()
# Send activation email (non-blocking)
try:
await email_service.send_membership_activation_email(
to_email=payment.membership.user.email,
first_name=payment.membership.user.first_name,
membership_tier=payment.membership.tier.name,
annual_fee=payment.membership.tier.annual_fee,
payment_amount=payment.amount,
payment_method=payment.payment_method.value,
renewal_date=payment.membership.end_date.strftime("%d %B %Y"),
db=db
)
except Exception as e:
# Log error but don't fail the payment update
print(f"Failed to send membership activation email: {e}")
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,
limit: int = 100,
status: PaymentStatus | None = None,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""List all payments (admin only)"""
query = db.query(Payment)
if status:
query = query.filter(Payment.status == status)
payments = query.order_by(Payment.created_at.desc()).offset(skip).limit(limit).all()
return payments
@router.post("/manual-payment", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
async def record_manual_payment(
user_id: int,
payment_data: PaymentCreate,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Record a manual payment (cash/check) for a user (admin only)"""
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Verify membership if provided
membership = None
if payment_data.membership_id:
membership = db.query(Membership).filter(
Membership.id == payment_data.membership_id,
Membership.user_id == user_id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership not found or does not belong to user"
)
payment = Payment(
user_id=user_id,
membership_id=payment_data.membership_id,
amount=payment_data.amount,
payment_method=payment_data.payment_method,
notes=payment_data.notes,
status=PaymentStatus.COMPLETED,
payment_date=datetime.utcnow()
)
db.add(payment)
db.commit()
db.refresh(payment)
# If payment has an associated membership that's pending, activate it and send email
if membership and membership.status == MembershipStatus.PENDING:
membership.status = MembershipStatus.ACTIVE
db.commit()
# Send activation email (non-blocking)
try:
await email_service.send_membership_activation_email(
to_email=membership.user.email,
first_name=membership.user.first_name,
membership_tier=membership.tier.name,
annual_fee=membership.tier.annual_fee,
payment_amount=payment.amount,
payment_method=payment.payment_method.value,
renewal_date=membership.end_date.strftime("%d %B %Y"),
db=db
)
except Exception as e:
# Log error but don't fail the payment creation
print(f"Failed to send membership activation email: {e}")
return payment