Files
membership-be/server.py
Koncept Kit 6ef7685ade first commit
2025-12-05 16:43:37 +07:00

1191 lines
39 KiB
Python

from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from sqlalchemy import or_
from pydantic import BaseModel, EmailStr, Field, validator
from typing import List, Optional, Literal
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv
from pathlib import Path
from contextlib import asynccontextmanager
import os
import logging
import uuid
import secrets
from database import engine, get_db, Base
from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus
from auth import (
get_password_hash,
verify_password,
create_access_token,
get_current_user,
get_current_admin_user
)
from email_service import send_verification_email, send_approval_notification, send_payment_prompt_email
from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date
# Load environment variables
ROOT_DIR = Path(__file__).parent
load_dotenv(ROOT_DIR / '.env')
# Create database tables
Base.metadata.create_all(bind=engine)
# Lifespan event handler (replaces deprecated on_event)
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
logger.info("Application started")
yield
# Shutdown
logger.info("Application shutdown")
# Create the main app
app = FastAPI(lifespan=lifespan)
# Create a router with the /api prefix
api_router = APIRouter(prefix="/api")
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Pydantic Models
class RegisterRequest(BaseModel):
email: EmailStr
password: str = Field(min_length=6)
first_name: str
last_name: str
phone: str
address: str
city: str
state: str
zipcode: str
date_of_birth: datetime
lead_sources: List[str]
partner_first_name: Optional[str] = None
partner_last_name: Optional[str] = None
partner_is_member: Optional[bool] = False
partner_plan_to_become_member: Optional[bool] = False
referred_by_member_name: Optional[str] = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
class LoginResponse(BaseModel):
access_token: str
token_type: str
user: dict
class UserResponse(BaseModel):
id: str
email: str
first_name: str
last_name: str
phone: str
address: str
city: str
state: str
zipcode: str
date_of_birth: datetime
status: str
role: str
email_verified: bool
created_at: datetime
model_config = {"from_attributes": True}
class UpdateProfileRequest(BaseModel):
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
zipcode: Optional[str] = None
class EventCreate(BaseModel):
title: str
description: Optional[str] = None
start_at: datetime
end_at: datetime
location: str
capacity: Optional[int] = None
published: bool = False
class EventUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
start_at: Optional[datetime] = None
end_at: Optional[datetime] = None
location: Optional[str] = None
capacity: Optional[int] = None
published: Optional[bool] = None
class EventResponse(BaseModel):
id: str
title: str
description: Optional[str]
start_at: datetime
end_at: datetime
location: str
capacity: Optional[int]
published: bool
created_by: str
created_at: datetime
rsvp_count: Optional[int] = 0
user_rsvp_status: Optional[str] = None
model_config = {"from_attributes": True}
class RSVPRequest(BaseModel):
rsvp_status: str
class AttendanceUpdate(BaseModel):
user_id: str
attended: bool
class UpdateUserStatusRequest(BaseModel):
status: str
class ManualPaymentRequest(BaseModel):
plan_id: str = Field(..., description="Subscription plan ID")
amount_cents: int = Field(..., description="Payment amount in cents")
payment_date: datetime = Field(..., description="Date payment was received")
payment_method: str = Field(..., description="Payment method: cash, bank_transfer, check, other")
use_custom_period: bool = Field(False, description="Whether to use custom dates instead of plan's billing cycle")
custom_period_start: Optional[datetime] = Field(None, description="Custom subscription start date")
custom_period_end: Optional[datetime] = Field(None, description="Custom subscription end date")
notes: Optional[str] = Field(None, description="Admin notes about payment")
# Auth Routes
@api_router.post("/auth/register")
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
# Check if email already exists
existing_user = db.query(User).filter(User.email == request.email).first()
if existing_user:
raise HTTPException(status_code=400, detail="Email already registered")
# Generate verification token
verification_token = secrets.token_urlsafe(32)
# Create user
user = User(
email=request.email,
password_hash=get_password_hash(request.password),
first_name=request.first_name,
last_name=request.last_name,
phone=request.phone,
address=request.address,
city=request.city,
state=request.state,
zipcode=request.zipcode,
date_of_birth=request.date_of_birth,
lead_sources=request.lead_sources,
partner_first_name=request.partner_first_name,
partner_last_name=request.partner_last_name,
partner_is_member=request.partner_is_member,
partner_plan_to_become_member=request.partner_plan_to_become_member,
referred_by_member_name=request.referred_by_member_name,
status=UserStatus.pending_email,
role=UserRole.guest,
email_verified=False,
email_verification_token=verification_token
)
db.add(user)
db.commit()
db.refresh(user)
# Send verification email
await send_verification_email(user.email, verification_token)
logger.info(f"User registered: {user.email}")
return {"message": "Registration successful. Please check your email to verify your account."}
@api_router.get("/auth/verify-email")
async def verify_email(token: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email_verification_token == token).first()
if not user:
raise HTTPException(status_code=400, detail="Invalid verification token")
# Check if referred by current member - skip validation requirement
if user.referred_by_member_name:
referrer = db.query(User).filter(
or_(
User.first_name + ' ' + User.last_name == user.referred_by_member_name,
User.email == user.referred_by_member_name
),
User.status == UserStatus.active
).first()
if referrer:
user.status = UserStatus.pre_approved
else:
user.status = UserStatus.pending_approval
else:
user.status = UserStatus.pending_approval
user.email_verified = True
user.email_verification_token = None
db.commit()
db.refresh(user)
logger.info(f"Email verified for user: {user.email}")
return {"message": "Email verified successfully", "status": user.status.value}
@api_router.post("/auth/login", response_model=LoginResponse)
async def login(request: LoginRequest, db: Session = Depends(get_db)):
user = db.query(User).filter(User.email == request.email).first()
if not user or not verify_password(request.password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password"
)
access_token = create_access_token(data={"sub": str(user.id)})
return {
"access_token": access_token,
"token_type": "bearer",
"user": {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"status": user.status.value,
"role": user.role.value
}
}
@api_router.get("/auth/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)):
return UserResponse(
id=str(current_user.id),
email=current_user.email,
first_name=current_user.first_name,
last_name=current_user.last_name,
phone=current_user.phone,
address=current_user.address,
city=current_user.city,
state=current_user.state,
zipcode=current_user.zipcode,
date_of_birth=current_user.date_of_birth,
status=current_user.status.value,
role=current_user.role.value,
email_verified=current_user.email_verified,
created_at=current_user.created_at
)
# User Profile Routes
@api_router.get("/users/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(get_current_user)):
return UserResponse(
id=str(current_user.id),
email=current_user.email,
first_name=current_user.first_name,
last_name=current_user.last_name,
phone=current_user.phone,
address=current_user.address,
city=current_user.city,
state=current_user.state,
zipcode=current_user.zipcode,
date_of_birth=current_user.date_of_birth,
status=current_user.status.value,
role=current_user.role.value,
email_verified=current_user.email_verified,
created_at=current_user.created_at
)
@api_router.put("/users/profile")
async def update_profile(
request: UpdateProfileRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
if request.first_name:
current_user.first_name = request.first_name
if request.last_name:
current_user.last_name = request.last_name
if request.phone:
current_user.phone = request.phone
if request.address:
current_user.address = request.address
if request.city:
current_user.city = request.city
if request.state:
current_user.state = request.state
if request.zipcode:
current_user.zipcode = request.zipcode
current_user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(current_user)
return {"message": "Profile updated successfully"}
# Event Routes
@api_router.get("/events", response_model=List[EventResponse])
async def get_events(
db: Session = Depends(get_db)
):
# Get published events for all users
events = db.query(Event).filter(Event.published == True).order_by(Event.start_at).all()
result = []
for event in events:
rsvp_count = db.query(EventRSVP).filter(
EventRSVP.event_id == event.id,
EventRSVP.rsvp_status == RSVPStatus.yes
).count()
# No user_rsvp_status in public endpoint
result.append(EventResponse(
id=str(event.id),
title=event.title,
description=event.description,
start_at=event.start_at,
end_at=event.end_at,
location=event.location,
capacity=event.capacity,
published=event.published,
created_by=str(event.created_by),
created_at=event.created_at,
rsvp_count=rsvp_count,
user_rsvp_status=None
))
return result
@api_router.get("/events/{event_id}", response_model=EventResponse)
async def get_event(
event_id: str,
db: Session = Depends(get_db)
):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
rsvp_count = db.query(EventRSVP).filter(
EventRSVP.event_id == event.id,
EventRSVP.rsvp_status == RSVPStatus.yes
).count()
# No user_rsvp_status in public endpoint
user_rsvp = None
return EventResponse(
id=str(event.id),
title=event.title,
description=event.description,
start_at=event.start_at,
end_at=event.end_at,
location=event.location,
capacity=event.capacity,
published=event.published,
created_by=str(event.created_by),
created_at=event.created_at,
rsvp_count=rsvp_count,
user_rsvp_status=user_rsvp
)
@api_router.post("/events/{event_id}/rsvp")
async def rsvp_to_event(
event_id: str,
request: RSVPRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Check if RSVP already exists
existing_rsvp = db.query(EventRSVP).filter(
EventRSVP.event_id == event_id,
EventRSVP.user_id == current_user.id
).first()
if existing_rsvp:
existing_rsvp.rsvp_status = RSVPStatus(request.rsvp_status)
existing_rsvp.updated_at = datetime.now(timezone.utc)
else:
rsvp = EventRSVP(
event_id=event.id,
user_id=current_user.id,
rsvp_status=RSVPStatus(request.rsvp_status)
)
db.add(rsvp)
db.commit()
return {"message": "RSVP updated successfully"}
# Admin Routes
@api_router.get("/admin/users")
async def get_all_users(
status: Optional[str] = None,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
query = db.query(User)
if status:
try:
status_enum = UserStatus(status)
query = query.filter(User.status == status_enum)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid status")
users = query.order_by(User.created_at.desc()).all()
return [
{
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone": user.phone,
"status": user.status.value,
"role": user.role.value,
"email_verified": user.email_verified,
"created_at": user.created_at.isoformat(),
"lead_sources": user.lead_sources,
"referred_by_member_name": user.referred_by_member_name
}
for user in users
]
@api_router.get("/admin/users/{user_id}")
async def get_user_by_id(
user_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Get specific user by ID (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
return {
"id": str(user.id),
"email": user.email,
"first_name": user.first_name,
"last_name": user.last_name,
"phone": user.phone,
"address": user.address,
"city": user.city,
"state": user.state,
"zipcode": user.zipcode,
"date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None,
"partner_first_name": user.partner_first_name,
"partner_last_name": user.partner_last_name,
"partner_is_member": user.partner_is_member,
"partner_plan_to_become_member": user.partner_plan_to_become_member,
"referred_by_member_name": user.referred_by_member_name,
"status": user.status.value,
"role": user.role.value,
"email_verified": user.email_verified,
"newsletter_subscribed": user.newsletter_subscribed,
"lead_sources": user.lead_sources,
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None
}
@api_router.put("/admin/users/{user_id}/approve")
async def approve_user(
user_id: str,
bypass_email_verification: bool = False,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Handle bypass email verification for pending_email users
if bypass_email_verification and user.status == UserStatus.pending_email:
# Verify email manually
user.email_verified = True
user.email_verification_token = None
# Determine status based on referral
if user.referred_by_member_name:
referrer = db.query(User).filter(
or_(
User.first_name + ' ' + User.last_name == user.referred_by_member_name,
User.email == user.referred_by_member_name
),
User.status == UserStatus.active
).first()
user.status = UserStatus.pre_approved if referrer else UserStatus.pending_approval
else:
user.status = UserStatus.pending_approval
logger.info(f"Admin {current_user.email} bypassed email verification for {user.email}")
# Validate user status - must be pending_approval or pre_approved
if user.status not in [UserStatus.pending_approval, UserStatus.pre_approved]:
raise HTTPException(
status_code=400,
detail=f"User must have verified email first. Current: {user.status.value}"
)
# Set to payment_pending - user becomes active after payment via webhook
user.status = UserStatus.payment_pending
user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
# Send payment prompt email
await send_payment_prompt_email(user.email, user.first_name)
logger.info(f"User validated and approved (payment pending): {user.email} by admin: {current_user.email}")
return {"message": "User approved - payment email sent"}
@api_router.put("/admin/users/{user_id}/status")
async def update_user_status(
user_id: str,
request: UpdateUserStatusRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
try:
new_status = UserStatus(request.status)
user.status = new_status
user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
return {"message": "User status updated successfully"}
except ValueError:
raise HTTPException(status_code=400, detail="Invalid status")
@api_router.post("/admin/users/{user_id}/activate-payment")
async def activate_payment_manually(
user_id: str,
request: ManualPaymentRequest,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_admin_user)
):
"""Manually activate user who paid offline (cash, bank transfer, etc.)"""
# 1. Find user
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# 2. Validate status
if user.status != UserStatus.payment_pending:
raise HTTPException(
status_code=400,
detail=f"User must be in payment_pending status. Current: {user.status.value}"
)
# 3. Get subscription plan
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == request.plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Subscription plan not found")
# 4. Calculate subscription period
if request.use_custom_period:
# Use admin-specified custom dates
if not request.custom_period_start or not request.custom_period_end:
raise HTTPException(
status_code=400,
detail="Custom period start and end dates are required when use_custom_period is true"
)
period_start = request.custom_period_start
period_end = request.custom_period_end
else:
# Use plan's billing cycle
period_start = datetime.now(timezone.utc)
if plan.billing_cycle == 'monthly':
period_end = period_start + timedelta(days=30)
elif plan.billing_cycle == 'quarterly':
period_end = period_start + timedelta(days=90)
elif plan.billing_cycle == 'yearly':
period_end = period_start + timedelta(days=365)
elif plan.billing_cycle == 'lifetime':
period_end = period_start + timedelta(days=36500) # 100 years
else:
period_end = period_start + timedelta(days=365) # Default 1 year
# 5. Create subscription record (manual payment)
subscription = Subscription(
user_id=user.id,
plan_id=plan.id,
stripe_subscription_id=None, # No Stripe involvement
stripe_customer_id=None,
status=SubscriptionStatus.active,
start_date=period_start,
end_date=period_end,
amount_paid_cents=request.amount_cents,
payment_method=request.payment_method,
manual_payment=True,
manual_payment_notes=request.notes,
manual_payment_admin_id=current_user.id,
manual_payment_date=request.payment_date
)
db.add(subscription)
# 6. Activate user
user.status = UserStatus.active
user.role = UserRole.member
user.updated_at = datetime.now(timezone.utc)
# 7. Commit
db.commit()
db.refresh(subscription)
# 8. Log admin action
logger.info(
f"Admin {current_user.email} manually activated payment for user {user.email} "
f"via {request.payment_method} for ${request.amount_cents/100:.2f} "
f"with plan {plan.name} ({period_start.date()} to {period_end.date()})"
)
return {
"message": "User payment activated successfully",
"user_id": str(user.id),
"subscription_id": str(subscription.id)
}
@api_router.post("/admin/events", response_model=EventResponse)
async def create_event(
request: EventCreate,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
event = Event(
title=request.title,
description=request.description,
start_at=request.start_at,
end_at=request.end_at,
location=request.location,
capacity=request.capacity,
published=request.published,
created_by=current_user.id
)
db.add(event)
db.commit()
db.refresh(event)
logger.info(f"Event created: {event.title} by {current_user.email}")
return EventResponse(
id=str(event.id),
title=event.title,
description=event.description,
start_at=event.start_at,
end_at=event.end_at,
location=event.location,
capacity=event.capacity,
published=event.published,
created_by=str(event.created_by),
created_at=event.created_at,
rsvp_count=0
)
@api_router.put("/admin/events/{event_id}")
async def update_event(
event_id: str,
request: EventUpdate,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
if request.title:
event.title = request.title
if request.description is not None:
event.description = request.description
if request.start_at:
event.start_at = request.start_at
if request.end_at:
event.end_at = request.end_at
if request.location:
event.location = request.location
if request.capacity is not None:
event.capacity = request.capacity
if request.published is not None:
event.published = request.published
event.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(event)
return {"message": "Event updated successfully"}
@api_router.get("/admin/events/{event_id}/rsvps")
async def get_event_rsvps(
event_id: str,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
rsvps = db.query(EventRSVP).filter(EventRSVP.event_id == event_id).all()
result = []
for rsvp in rsvps:
user = db.query(User).filter(User.id == rsvp.user_id).first()
result.append({
"id": str(rsvp.id),
"user_id": str(rsvp.user_id),
"user_name": f"{user.first_name} {user.last_name}",
"user_email": user.email,
"rsvp_status": rsvp.rsvp_status.value,
"attended": rsvp.attended,
"attended_at": rsvp.attended_at.isoformat() if rsvp.attended_at else None
})
return result
@api_router.put("/admin/events/{event_id}/attendance")
async def mark_attendance(
event_id: str,
request: AttendanceUpdate,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
rsvp = db.query(EventRSVP).filter(
EventRSVP.event_id == event_id,
EventRSVP.user_id == request.user_id
).first()
if not rsvp:
raise HTTPException(status_code=404, detail="RSVP not found")
rsvp.attended = request.attended
rsvp.attended_at = datetime.now(timezone.utc) if request.attended else None
rsvp.updated_at = datetime.now(timezone.utc)
# If user attended and they were pending approval, update their status
if request.attended:
user = db.query(User).filter(User.id == request.user_id).first()
if user and user.status == UserStatus.pending_approval:
user.status = UserStatus.pre_approved
user.updated_at = datetime.now(timezone.utc)
db.commit()
return {"message": "Attendance marked successfully"}
@api_router.get("/admin/events")
async def get_admin_events(
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Get all events for admin (including unpublished)"""
events = db.query(Event).order_by(Event.start_at.desc()).all()
result = []
for event in events:
rsvp_count = db.query(EventRSVP).filter(
EventRSVP.event_id == event.id,
EventRSVP.rsvp_status == RSVPStatus.yes
).count()
result.append({
"id": str(event.id),
"title": event.title,
"description": event.description,
"start_at": event.start_at,
"end_at": event.end_at,
"location": event.location,
"capacity": event.capacity,
"published": event.published,
"created_by": str(event.created_by),
"created_at": event.created_at,
"rsvp_count": rsvp_count
})
return result
@api_router.delete("/admin/events/{event_id}")
async def delete_event(
event_id: str,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Delete an event (cascade deletes RSVPs)"""
event = db.query(Event).filter(Event.id == event_id).first()
if not event:
raise HTTPException(status_code=404, detail="Event not found")
db.delete(event)
db.commit()
return {"message": "Event deleted successfully"}
# ==================== PAYMENT & SUBSCRIPTION ENDPOINTS ====================
# Pydantic model for checkout request
class CheckoutRequest(BaseModel):
plan_id: str
# Pydantic model for plan CRUD
class PlanCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500)
price_cents: int = Field(ge=0, le=100000000)
billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime"]
stripe_price_id: Optional[str] = None
active: bool = True
@validator('name')
def validate_name(cls, v):
if not v.strip():
raise ValueError('Name cannot be empty or whitespace')
return v.strip()
@api_router.get("/subscriptions/plans")
async def get_subscription_plans(db: Session = Depends(get_db)):
"""Get all active subscription plans."""
plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.active == True).all()
return plans
# ==================== ADMIN PLAN CRUD ENDPOINTS ====================
@api_router.get("/admin/subscriptions/plans")
async def get_all_plans_admin(
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Get all subscription plans for admin (including inactive) with subscriber counts."""
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.created_at.desc()).all()
result = []
for plan in plans:
subscriber_count = db.query(Subscription).filter(
Subscription.plan_id == plan.id,
Subscription.status == SubscriptionStatus.active
).count()
result.append({
"id": str(plan.id),
"name": plan.name,
"description": plan.description,
"price_cents": plan.price_cents,
"billing_cycle": plan.billing_cycle,
"stripe_price_id": plan.stripe_price_id,
"active": plan.active,
"subscriber_count": subscriber_count,
"created_at": plan.created_at,
"updated_at": plan.updated_at
})
return result
@api_router.get("/admin/subscriptions/plans/{plan_id}")
async def get_plan_admin(
plan_id: str,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Get single plan details with subscriber count."""
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")
subscriber_count = db.query(Subscription).filter(
Subscription.plan_id == plan.id,
Subscription.status == SubscriptionStatus.active
).count()
return {
"id": str(plan.id),
"name": plan.name,
"description": plan.description,
"price_cents": plan.price_cents,
"billing_cycle": plan.billing_cycle,
"stripe_price_id": plan.stripe_price_id,
"active": plan.active,
"subscriber_count": subscriber_count,
"created_at": plan.created_at,
"updated_at": plan.updated_at
}
@api_router.post("/admin/subscriptions/plans")
async def create_plan(
request: PlanCreateRequest,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Create new subscription plan."""
# Check for duplicate name
existing = db.query(SubscriptionPlan).filter(
SubscriptionPlan.name == request.name
).first()
if existing:
raise HTTPException(
status_code=400,
detail="A plan with this name already exists"
)
plan = SubscriptionPlan(
name=request.name,
description=request.description,
price_cents=request.price_cents,
billing_cycle=request.billing_cycle,
stripe_price_id=request.stripe_price_id,
active=request.active
)
db.add(plan)
db.commit()
db.refresh(plan)
logger.info(f"Admin {current_user.email} created plan: {plan.name}")
return {
"id": str(plan.id),
"name": plan.name,
"description": plan.description,
"price_cents": plan.price_cents,
"billing_cycle": plan.billing_cycle,
"stripe_price_id": plan.stripe_price_id,
"active": plan.active,
"subscriber_count": 0,
"created_at": plan.created_at,
"updated_at": plan.updated_at
}
@api_router.put("/admin/subscriptions/plans/{plan_id}")
async def update_plan(
plan_id: str,
request: PlanCreateRequest,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Update subscription plan."""
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")
# Check for duplicate name (excluding current plan)
existing = db.query(SubscriptionPlan).filter(
SubscriptionPlan.name == request.name,
SubscriptionPlan.id != plan_id
).first()
if existing:
raise HTTPException(
status_code=400,
detail="A plan with this name already exists"
)
# Update fields
plan.name = request.name
plan.description = request.description
plan.price_cents = request.price_cents
plan.billing_cycle = request.billing_cycle
plan.stripe_price_id = request.stripe_price_id
plan.active = request.active
plan.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(plan)
logger.info(f"Admin {current_user.email} updated plan: {plan.name}")
subscriber_count = db.query(Subscription).filter(
Subscription.plan_id == plan.id,
Subscription.status == SubscriptionStatus.active
).count()
return {
"id": str(plan.id),
"name": plan.name,
"description": plan.description,
"price_cents": plan.price_cents,
"billing_cycle": plan.billing_cycle,
"stripe_price_id": plan.stripe_price_id,
"active": plan.active,
"subscriber_count": subscriber_count,
"created_at": plan.created_at,
"updated_at": plan.updated_at
}
@api_router.delete("/admin/subscriptions/plans/{plan_id}")
async def delete_plan(
plan_id: str,
current_user: User = Depends(get_current_admin_user),
db: Session = Depends(get_db)
):
"""Soft delete plan (set active = False)."""
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first()
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")
# Check if plan has active subscriptions
active_subs = db.query(Subscription).filter(
Subscription.plan_id == plan_id,
Subscription.status == SubscriptionStatus.active
).count()
if active_subs > 0:
raise HTTPException(
status_code=400,
detail=f"Cannot delete plan with {active_subs} active subscriptions"
)
plan.active = False
plan.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info(f"Admin {current_user.email} deactivated plan: {plan.name}")
return {"message": "Plan deactivated successfully"}
@api_router.post("/subscriptions/checkout")
async def create_checkout(
request: CheckoutRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create Stripe Checkout session for subscription payment."""
# Get plan
plan = db.query(SubscriptionPlan).filter(
SubscriptionPlan.id == request.plan_id
).first()
if not plan:
raise HTTPException(status_code=404, detail="Plan not found")
if not plan.active:
raise HTTPException(status_code=400, detail="This plan is no longer available for subscription")
if not plan.stripe_price_id:
raise HTTPException(status_code=400, detail="Plan is not configured for payment")
# Get frontend URL from env
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
try:
# Create checkout session
session = create_checkout_session(
user_id=current_user.id,
user_email=current_user.email,
plan_id=plan.id,
stripe_price_id=plan.stripe_price_id,
success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{frontend_url}/payment-cancel"
)
return {"checkout_url": session["url"]}
except Exception as e:
logger.error(f"Error creating checkout session: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create checkout session")
@app.post("/api/webhooks/stripe")
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
"""Handle Stripe webhook events. Note: This endpoint is NOT on the api_router to avoid /api/api prefix."""
# Get raw payload and signature
payload = await request.body()
sig_header = request.headers.get("stripe-signature")
if not sig_header:
raise HTTPException(status_code=400, detail="Missing stripe-signature header")
try:
# Verify webhook signature
event = verify_webhook_signature(payload, sig_header)
except ValueError as e:
logger.error(f"Webhook signature verification failed: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
# Handle checkout.session.completed event
if event["type"] == "checkout.session.completed":
session = event["data"]["object"]
# Get metadata
user_id = session["metadata"].get("user_id")
plan_id = session["metadata"].get("plan_id")
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:
# Create subscription record
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=datetime.now(timezone.utc),
end_date=get_subscription_end_date(plan.billing_cycle),
amount_paid_cents=session.get("amount_total", plan.price_cents)
)
db.add(subscription)
# Update user status and role
user.status = UserStatus.active
user.role = UserRole.member
user.updated_at = datetime.now(timezone.utc)
db.commit()
logger.info(f"Subscription created for user {user.email}")
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"}
# Include the router in the main app
app.include_router(api_router)
app.add_middleware(
CORSMiddleware,
allow_credentials=True,
allow_origins=os.environ.get('CORS_ORIGINS', '*').split(','),
allow_methods=["*"],
allow_headers=["*"],
)