forked from andika/membership-be
- Profile Picture\
Donation Tracking\ Validation Rejection\ Subscription Data Export\ Admin Dashboard Logo\ Admin Navbar Reorganization
This commit is contained in:
631
server.py
631
server.py
@@ -17,7 +17,7 @@ import csv
|
||||
import io
|
||||
|
||||
from database import engine, get_db, Base
|
||||
from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus
|
||||
from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, Donation, DonationType, DonationStatus
|
||||
from auth import (
|
||||
get_password_hash,
|
||||
verify_password,
|
||||
@@ -208,6 +208,8 @@ class UserResponse(BaseModel):
|
||||
role: str
|
||||
email_verified: bool
|
||||
created_at: datetime
|
||||
# Profile
|
||||
profile_photo_url: Optional[str] = None
|
||||
# Subscription info (optional)
|
||||
subscription_start_date: Optional[datetime] = None
|
||||
subscription_end_date: Optional[datetime] = None
|
||||
@@ -764,6 +766,20 @@ async def get_my_permissions(
|
||||
"role": get_user_role_code(current_user)
|
||||
}
|
||||
|
||||
@api_router.get("/config")
|
||||
async def get_config():
|
||||
"""
|
||||
Get public configuration values
|
||||
Returns: max_file_size_bytes, max_file_size_mb
|
||||
"""
|
||||
max_file_size_bytes = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) # Default 50MB
|
||||
max_file_size_mb = max_file_size_bytes / (1024 * 1024)
|
||||
|
||||
return {
|
||||
"max_file_size_bytes": max_file_size_bytes,
|
||||
"max_file_size_mb": int(max_file_size_mb)
|
||||
}
|
||||
|
||||
# User Profile Routes
|
||||
@api_router.get("/users/profile", response_model=UserResponse)
|
||||
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||
@@ -2104,6 +2120,7 @@ async def get_user_by_id(
|
||||
"state": user.state,
|
||||
"zipcode": user.zipcode,
|
||||
"date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None,
|
||||
"profile_photo_url": user.profile_photo_url,
|
||||
"partner_first_name": user.partner_first_name,
|
||||
"partner_last_name": user.partner_last_name,
|
||||
"partner_is_member": user.partner_is_member,
|
||||
@@ -2194,6 +2211,52 @@ async def update_user_status(
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid status")
|
||||
|
||||
@api_router.post("/admin/users/{user_id}/reject")
|
||||
async def reject_user(
|
||||
user_id: str,
|
||||
rejection_data: dict,
|
||||
current_user: User = Depends(require_permission("users.approve")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Reject a user's membership application with mandatory reason"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
reason = rejection_data.get("reason", "").strip()
|
||||
if not reason:
|
||||
raise HTTPException(status_code=400, detail="Rejection reason is required")
|
||||
|
||||
# Update user status to rejected
|
||||
user.status = UserStatus.rejected
|
||||
user.rejection_reason = reason
|
||||
user.rejected_at = datetime.now(timezone.utc)
|
||||
user.rejected_by = current_user.id
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
|
||||
# Send rejection email
|
||||
try:
|
||||
from email_service import send_rejection_email
|
||||
await send_rejection_email(
|
||||
user.email,
|
||||
user.first_name,
|
||||
reason
|
||||
)
|
||||
logger.info(f"Rejection email sent to {user.email}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send rejection email to {user.email}: {str(e)}")
|
||||
# Don't fail the request if email fails
|
||||
|
||||
logger.info(f"Admin {current_user.email} rejected user {user.email}")
|
||||
|
||||
return {
|
||||
"message": "User rejected successfully",
|
||||
"user_id": str(user.id),
|
||||
"status": user.status.value
|
||||
}
|
||||
|
||||
@api_router.post("/admin/users/{user_id}/activate-payment")
|
||||
async def activate_payment_manually(
|
||||
user_id: str,
|
||||
@@ -2356,6 +2419,114 @@ async def admin_resend_verification(
|
||||
|
||||
return {"message": f"Verification email resent to {user.email}"}
|
||||
|
||||
@api_router.post("/admin/users/{user_id}/upload-photo")
|
||||
async def admin_upload_user_profile_photo(
|
||||
user_id: str,
|
||||
file: UploadFile = File(...),
|
||||
current_user: User = Depends(require_permission("users.edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin uploads profile photo for a specific user"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
r2 = get_r2_storage()
|
||||
|
||||
# Get storage quota
|
||||
storage = db.query(StorageUsage).first()
|
||||
if not storage:
|
||||
storage = StorageUsage(
|
||||
total_bytes_used=0,
|
||||
max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240))
|
||||
)
|
||||
db.add(storage)
|
||||
db.commit()
|
||||
db.refresh(storage)
|
||||
|
||||
# Get max file size from env
|
||||
max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800))
|
||||
|
||||
# Delete old profile photo if exists
|
||||
if user.profile_photo_url:
|
||||
old_key = user.profile_photo_url.split('/')[-1]
|
||||
old_key = f"profiles/{old_key}"
|
||||
try:
|
||||
old_size = await r2.get_file_size(old_key)
|
||||
await r2.delete_file(old_key)
|
||||
storage.total_bytes_used -= old_size
|
||||
except:
|
||||
pass
|
||||
|
||||
# Upload new photo
|
||||
try:
|
||||
public_url, object_key, file_size = await r2.upload_file(
|
||||
file=file,
|
||||
folder="profiles",
|
||||
max_size_bytes=max_file_size
|
||||
)
|
||||
|
||||
user.profile_photo_url = public_url
|
||||
storage.total_bytes_used += file_size
|
||||
storage.last_updated = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_user.email} uploaded profile photo for user {user.email}: {file_size} bytes")
|
||||
|
||||
return {
|
||||
"message": "Profile photo uploaded successfully",
|
||||
"profile_photo_url": public_url
|
||||
}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||
|
||||
@api_router.delete("/admin/users/{user_id}/delete-photo")
|
||||
async def admin_delete_user_profile_photo(
|
||||
user_id: str,
|
||||
current_user: User = Depends(require_permission("users.edit")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Admin deletes profile photo for a specific user"""
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if not user:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if not user.profile_photo_url:
|
||||
raise HTTPException(status_code=404, detail="User has no profile photo")
|
||||
|
||||
r2 = get_r2_storage()
|
||||
|
||||
# Extract object key from URL
|
||||
object_key = user.profile_photo_url.split('/')[-1]
|
||||
object_key = f"profiles/{object_key}"
|
||||
|
||||
try:
|
||||
# Get file size before deletion for storage tracking
|
||||
storage = db.query(StorageUsage).first()
|
||||
if storage:
|
||||
try:
|
||||
file_size = await r2.get_file_size(object_key)
|
||||
storage.total_bytes_used -= file_size
|
||||
storage.last_updated = datetime.now(timezone.utc)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Delete from R2
|
||||
await r2.delete_file(object_key)
|
||||
|
||||
# Remove URL from user record
|
||||
user.profile_photo_url = None
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Admin {current_user.email} deleted profile photo for user {user.email}")
|
||||
|
||||
return {"message": "Profile photo deleted successfully"}
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||
|
||||
# ============================================================
|
||||
# User Creation & Invitation Endpoints
|
||||
# ============================================================
|
||||
@@ -3648,6 +3819,287 @@ async def cancel_subscription(
|
||||
|
||||
return {"message": "Subscription cancelled successfully"}
|
||||
|
||||
@api_router.get("/admin/subscriptions/export")
|
||||
async def export_subscriptions(
|
||||
status: Optional[str] = None,
|
||||
plan_id: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: User = Depends(require_permission("subscriptions.export")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Export subscriptions to CSV for financial records"""
|
||||
|
||||
# Build query with same logic as get_all_subscriptions
|
||||
query = db.query(Subscription).join(Subscription.user).join(Subscription.plan)
|
||||
|
||||
# Apply filters
|
||||
if status:
|
||||
query = query.filter(Subscription.status == status)
|
||||
if plan_id:
|
||||
query = query.filter(Subscription.plan_id == plan_id)
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(User.first_name.ilike(search_term)) |
|
||||
(User.last_name.ilike(search_term)) |
|
||||
(User.email.ilike(search_term))
|
||||
)
|
||||
|
||||
subscriptions = query.order_by(Subscription.created_at.desc()).all()
|
||||
|
||||
# Create CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
# Header row
|
||||
writer.writerow([
|
||||
'Subscription ID', 'Member Name', 'Email', 'Plan Name', 'Billing Cycle',
|
||||
'Status', 'Base Amount', 'Donation Amount', 'Total Amount', 'Payment Method',
|
||||
'Start Date', 'End Date', 'Stripe Subscription ID', 'Created At', 'Updated At'
|
||||
])
|
||||
|
||||
# Data rows
|
||||
for sub in subscriptions:
|
||||
user = sub.user
|
||||
plan = sub.plan
|
||||
writer.writerow([
|
||||
str(sub.id),
|
||||
f"{user.first_name} {user.last_name}",
|
||||
user.email,
|
||||
plan.name,
|
||||
plan.billing_cycle,
|
||||
sub.status.value,
|
||||
f"${sub.base_subscription_cents / 100:.2f}",
|
||||
f"${sub.donation_cents / 100:.2f}" if sub.donation_cents else "$0.00",
|
||||
f"${sub.amount_paid_cents / 100:.2f}" if sub.amount_paid_cents else "$0.00",
|
||||
sub.payment_method or 'Stripe',
|
||||
sub.start_date.isoformat() if sub.start_date else '',
|
||||
sub.end_date.isoformat() if sub.end_date else '',
|
||||
sub.stripe_subscription_id or '',
|
||||
sub.created_at.isoformat() if sub.created_at else '',
|
||||
sub.updated_at.isoformat() if sub.updated_at else ''
|
||||
])
|
||||
|
||||
# Return CSV
|
||||
filename = f"subscriptions_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Admin Donation Management Routes
|
||||
# ============================================================================
|
||||
|
||||
@api_router.get("/admin/donations")
|
||||
async def get_donations(
|
||||
donation_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: User = Depends(require_permission("donations.view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get all donations with optional filters."""
|
||||
|
||||
query = db.query(Donation).outerjoin(User, Donation.user_id == User.id)
|
||||
|
||||
# Apply filters
|
||||
if donation_type:
|
||||
try:
|
||||
query = query.filter(Donation.donation_type == DonationType[donation_type])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid donation type: {donation_type}")
|
||||
|
||||
if status:
|
||||
try:
|
||||
query = query.filter(Donation.status == DonationStatus[status])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Donation.created_at >= start_dt)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid start_date format")
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Donation.created_at <= end_dt)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid end_date format")
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(Donation.donor_email.ilike(search_term)) |
|
||||
(Donation.donor_name.ilike(search_term)) |
|
||||
(User.first_name.ilike(search_term)) |
|
||||
(User.last_name.ilike(search_term))
|
||||
)
|
||||
|
||||
donations = query.order_by(Donation.created_at.desc()).all()
|
||||
|
||||
return [{
|
||||
"id": str(d.id),
|
||||
"amount_cents": d.amount_cents,
|
||||
"amount": f"${d.amount_cents / 100:.2f}",
|
||||
"donation_type": d.donation_type.value,
|
||||
"status": d.status.value,
|
||||
"donor_name": d.donor_name if d.donation_type == DonationType.public else (f"{d.user.first_name} {d.user.last_name}" if d.user else d.donor_name),
|
||||
"donor_email": d.donor_email or (d.user.email if d.user else None),
|
||||
"payment_method": d.payment_method,
|
||||
"notes": d.notes,
|
||||
"created_at": d.created_at.isoformat()
|
||||
} for d in donations]
|
||||
|
||||
@api_router.get("/admin/donations/stats")
|
||||
async def get_donation_stats(
|
||||
current_user: User = Depends(require_permission("donations.view")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get donation statistics."""
|
||||
from sqlalchemy import func
|
||||
|
||||
# Total donations
|
||||
total_donations = db.query(Donation).filter(
|
||||
Donation.status == DonationStatus.completed
|
||||
).count()
|
||||
|
||||
# Member donations
|
||||
member_donations = db.query(Donation).filter(
|
||||
Donation.status == DonationStatus.completed,
|
||||
Donation.donation_type == DonationType.member
|
||||
).count()
|
||||
|
||||
# Public donations
|
||||
public_donations = db.query(Donation).filter(
|
||||
Donation.status == DonationStatus.completed,
|
||||
Donation.donation_type == DonationType.public
|
||||
).count()
|
||||
|
||||
# Total amount
|
||||
total_amount = db.query(func.sum(Donation.amount_cents)).filter(
|
||||
Donation.status == DonationStatus.completed
|
||||
).scalar() or 0
|
||||
|
||||
# This month
|
||||
now = datetime.now(timezone.utc)
|
||||
this_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||||
this_month_amount = db.query(func.sum(Donation.amount_cents)).filter(
|
||||
Donation.status == DonationStatus.completed,
|
||||
Donation.created_at >= this_month_start
|
||||
).scalar() or 0
|
||||
|
||||
this_month_count = db.query(Donation).filter(
|
||||
Donation.status == DonationStatus.completed,
|
||||
Donation.created_at >= this_month_start
|
||||
).count()
|
||||
|
||||
return {
|
||||
"total_donations": total_donations,
|
||||
"member_donations": member_donations,
|
||||
"public_donations": public_donations,
|
||||
"total_amount_cents": total_amount,
|
||||
"total_amount": f"${total_amount / 100:.2f}",
|
||||
"this_month_amount_cents": this_month_amount,
|
||||
"this_month_amount": f"${this_month_amount / 100:.2f}",
|
||||
"this_month_count": this_month_count
|
||||
}
|
||||
|
||||
@api_router.get("/admin/donations/export")
|
||||
async def export_donations(
|
||||
donation_type: Optional[str] = None,
|
||||
status: Optional[str] = None,
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
current_user: User = Depends(require_permission("donations.export")),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Export donations to CSV."""
|
||||
import io
|
||||
import csv
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
# Build query (same as get_donations)
|
||||
query = db.query(Donation).outerjoin(User, Donation.user_id == User.id)
|
||||
|
||||
if donation_type:
|
||||
try:
|
||||
query = query.filter(Donation.donation_type == DonationType[donation_type])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid donation type: {donation_type}")
|
||||
|
||||
if status:
|
||||
try:
|
||||
query = query.filter(Donation.status == DonationStatus[status])
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid status: {status}")
|
||||
|
||||
if start_date:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Donation.created_at >= start_dt)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid start_date format")
|
||||
|
||||
if end_date:
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00'))
|
||||
query = query.filter(Donation.created_at <= end_dt)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid end_date format")
|
||||
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(Donation.donor_email.ilike(search_term)) |
|
||||
(Donation.donor_name.ilike(search_term)) |
|
||||
(User.first_name.ilike(search_term)) |
|
||||
(User.last_name.ilike(search_term))
|
||||
)
|
||||
|
||||
donations = query.order_by(Donation.created_at.desc()).all()
|
||||
|
||||
# Create CSV
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
|
||||
writer.writerow([
|
||||
'Donation ID', 'Date', 'Donor Name', 'Donor Email', 'Type',
|
||||
'Amount', 'Status', 'Payment Method', 'Stripe Payment Intent',
|
||||
'Notes'
|
||||
])
|
||||
|
||||
for d in donations:
|
||||
donor_name = d.donor_name if d.donation_type == DonationType.public else (f"{d.user.first_name} {d.user.last_name}" if d.user else d.donor_name)
|
||||
donor_email = d.donor_email or (d.user.email if d.user else '')
|
||||
|
||||
writer.writerow([
|
||||
str(d.id),
|
||||
d.created_at.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
donor_name or '',
|
||||
donor_email,
|
||||
d.donation_type.value,
|
||||
f"${d.amount_cents / 100:.2f}",
|
||||
d.status.value,
|
||||
d.payment_method or '',
|
||||
d.stripe_payment_intent_id or '',
|
||||
d.notes or ''
|
||||
])
|
||||
|
||||
filename = f"donations_export_{datetime.now().strftime('%Y%m%d')}.csv"
|
||||
return StreamingResponse(
|
||||
iter([output.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Admin Document Management Routes
|
||||
# ============================================================================
|
||||
@@ -4761,14 +5213,39 @@ async def create_checkout(
|
||||
@api_router.post("/donations/checkout")
|
||||
async def create_donation_checkout(
|
||||
request: DonationCheckoutRequest,
|
||||
db: Session = Depends(get_db)
|
||||
db: Session = Depends(get_db),
|
||||
current_user: Optional[User] = Depends(lambda: None) # Optional authentication
|
||||
):
|
||||
"""Create Stripe Checkout session for one-time donation."""
|
||||
|
||||
# Get frontend URL from env
|
||||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
||||
|
||||
# Check if user is authenticated (from header if present)
|
||||
try:
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from jose import jwt, JWTError
|
||||
|
||||
# Try to get token from request if available
|
||||
# For now, we'll make this work for both authenticated and anonymous
|
||||
pass
|
||||
except:
|
||||
pass
|
||||
|
||||
try:
|
||||
# Create donation record first
|
||||
donation = Donation(
|
||||
amount_cents=request.amount_cents,
|
||||
donation_type=DonationType.member if current_user else DonationType.public,
|
||||
user_id=current_user.id if current_user else None,
|
||||
donor_email=current_user.email if current_user else None,
|
||||
donor_name=f"{current_user.first_name} {current_user.last_name}" if current_user else None,
|
||||
status=DonationStatus.pending
|
||||
)
|
||||
db.add(donation)
|
||||
db.commit()
|
||||
db.refresh(donation)
|
||||
|
||||
# Create Stripe Checkout Session for one-time payment
|
||||
import stripe
|
||||
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||
@@ -4788,17 +5265,28 @@ async def create_donation_checkout(
|
||||
}],
|
||||
mode='payment', # One-time payment (not subscription)
|
||||
success_url=f"{frontend_url}/membership/donation-success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{frontend_url}/membership/donate"
|
||||
cancel_url=f"{frontend_url}/membership/donate",
|
||||
metadata={
|
||||
'donation_id': str(donation.id),
|
||||
'donation_type': donation.donation_type.value,
|
||||
'user_id': str(current_user.id) if current_user else None
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f}")
|
||||
# Update donation with session ID
|
||||
donation.stripe_checkout_session_id = checkout_session.id
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f} (ID: {donation.id})")
|
||||
|
||||
return {"checkout_url": checkout_session.url}
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
db.rollback()
|
||||
logger.error(f"Stripe error creating donation checkout: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}")
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error creating donation checkout: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to create donation checkout")
|
||||
|
||||
@@ -4911,64 +5399,95 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
||||
# Handle checkout.session.completed event
|
||||
if event["type"] == "checkout.session.completed":
|
||||
session = event["data"]["object"]
|
||||
metadata = session.get("metadata", {})
|
||||
|
||||
# Get metadata
|
||||
user_id = session["metadata"].get("user_id")
|
||||
plan_id = session["metadata"].get("plan_id")
|
||||
base_amount = int(session["metadata"].get("base_amount", 0))
|
||||
donation_amount = int(session["metadata"].get("donation_amount", 0))
|
||||
total_amount = int(session["metadata"].get("total_amount", session.get("amount_total", 0)))
|
||||
|
||||
if not user_id or not plan_id:
|
||||
logger.error("Missing user_id or plan_id in webhook metadata")
|
||||
return {"status": "error", "message": "Missing metadata"}
|
||||
|
||||
# Get user and plan
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
|
||||
|
||||
if user and plan:
|
||||
# Check if subscription already exists (idempotency)
|
||||
existing_subscription = db.query(Subscription).filter(
|
||||
Subscription.stripe_subscription_id == session.get("subscription")
|
||||
).first()
|
||||
|
||||
if not existing_subscription:
|
||||
# Calculate subscription period using custom billing cycle if enabled
|
||||
from payment_service import calculate_subscription_period
|
||||
start_date, end_date = calculate_subscription_period(plan)
|
||||
|
||||
# Create subscription record with donation tracking
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
plan_id=plan.id,
|
||||
stripe_subscription_id=session.get("subscription"),
|
||||
stripe_customer_id=session.get("customer"),
|
||||
status=SubscriptionStatus.active,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
amount_paid_cents=total_amount,
|
||||
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
||||
donation_cents=donation_amount,
|
||||
payment_method="stripe"
|
||||
)
|
||||
db.add(subscription)
|
||||
|
||||
# Update user status and role
|
||||
user.status = UserStatus.active
|
||||
set_user_role(user, UserRole.member, db)
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
# Check if this is a donation (has donation_id in metadata)
|
||||
if "donation_id" in metadata:
|
||||
donation_id = uuid.UUID(metadata["donation_id"])
|
||||
donation = db.query(Donation).filter(Donation.id == donation_id).first()
|
||||
|
||||
if donation:
|
||||
donation.status = DonationStatus.completed
|
||||
donation.stripe_payment_intent_id = session.get('payment_intent')
|
||||
donation.payment_method = 'card'
|
||||
donation.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Subscription created for user {user.email}: "
|
||||
f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}"
|
||||
)
|
||||
# Send thank you email
|
||||
try:
|
||||
from email_service import send_donation_thank_you_email
|
||||
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
|
||||
await send_donation_thank_you_email(
|
||||
donation.donor_email,
|
||||
donor_first_name,
|
||||
donation.amount_cents
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send donation thank you email: {str(e)}")
|
||||
|
||||
logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
|
||||
else:
|
||||
logger.info(f"Subscription already exists for session {session.get('id')}")
|
||||
logger.error(f"Donation not found: {donation_id}")
|
||||
|
||||
# Otherwise handle subscription payment (existing logic)
|
||||
else:
|
||||
logger.error(f"User or plan not found: user_id={user_id}, plan_id={plan_id}")
|
||||
# Get metadata
|
||||
user_id = metadata.get("user_id")
|
||||
plan_id = metadata.get("plan_id")
|
||||
base_amount = int(metadata.get("base_amount", 0))
|
||||
donation_amount = int(metadata.get("donation_amount", 0))
|
||||
total_amount = int(metadata.get("total_amount", session.get("amount_total", 0)))
|
||||
|
||||
if not user_id or not plan_id:
|
||||
logger.error("Missing user_id or plan_id in webhook metadata")
|
||||
return {"status": "error", "message": "Missing metadata"}
|
||||
|
||||
# Get user and plan
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
|
||||
|
||||
if user and plan:
|
||||
# Check if subscription already exists (idempotency)
|
||||
existing_subscription = db.query(Subscription).filter(
|
||||
Subscription.stripe_subscription_id == session.get("subscription")
|
||||
).first()
|
||||
|
||||
if not existing_subscription:
|
||||
# Calculate subscription period using custom billing cycle if enabled
|
||||
from payment_service import calculate_subscription_period
|
||||
start_date, end_date = calculate_subscription_period(plan)
|
||||
|
||||
# Create subscription record with donation tracking
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
plan_id=plan.id,
|
||||
stripe_subscription_id=session.get("subscription"),
|
||||
stripe_customer_id=session.get("customer"),
|
||||
status=SubscriptionStatus.active,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
amount_paid_cents=total_amount,
|
||||
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
||||
donation_cents=donation_amount,
|
||||
payment_method="stripe"
|
||||
)
|
||||
db.add(subscription)
|
||||
|
||||
# Update user status and role
|
||||
user.status = UserStatus.active
|
||||
set_user_role(user, UserRole.member, db)
|
||||
user.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Subscription created for user {user.email}: "
|
||||
f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Subscription already exists for session {session.get('id')}")
|
||||
else:
|
||||
logger.error(f"User or plan not found: user_id={user_id}, plan_id={plan_id}")
|
||||
|
||||
return {"status": "success"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user