forked from andika/membership-be
Update:- Membership Plan- Donation- Member detail for Member Directory
This commit is contained in:
494
server.py
494
server.py
@@ -173,10 +173,38 @@ class UserResponse(BaseModel):
|
||||
subscription_start_date: Optional[datetime] = None
|
||||
subscription_end_date: Optional[datetime] = None
|
||||
subscription_status: Optional[str] = None
|
||||
# Partner information
|
||||
partner_first_name: Optional[str] = None
|
||||
partner_last_name: Optional[str] = None
|
||||
partner_is_member: Optional[bool] = None
|
||||
partner_plan_to_become_member: Optional[bool] = None
|
||||
# Newsletter preferences
|
||||
newsletter_publish_name: Optional[bool] = None
|
||||
newsletter_publish_photo: Optional[bool] = None
|
||||
newsletter_publish_birthday: Optional[bool] = None
|
||||
newsletter_publish_none: Optional[bool] = None
|
||||
# Volunteer interests
|
||||
volunteer_interests: Optional[list] = None
|
||||
# Directory settings
|
||||
show_in_directory: Optional[bool] = None
|
||||
directory_email: Optional[str] = None
|
||||
directory_bio: Optional[str] = None
|
||||
directory_address: Optional[str] = None
|
||||
directory_phone: Optional[str] = None
|
||||
directory_dob: Optional[datetime] = None
|
||||
directory_partner_name: Optional[str] = None
|
||||
|
||||
model_config = {"from_attributes": True}
|
||||
|
||||
@validator('id', 'status', 'role', pre=True)
|
||||
def convert_to_string(cls, v):
|
||||
"""Convert UUID and Enum types to strings"""
|
||||
if hasattr(v, 'value'):
|
||||
return v.value
|
||||
return str(v)
|
||||
|
||||
class UpdateProfileRequest(BaseModel):
|
||||
# Basic personal information
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
@@ -185,6 +213,37 @@ class UpdateProfileRequest(BaseModel):
|
||||
state: Optional[str] = None
|
||||
zipcode: Optional[str] = None
|
||||
|
||||
# Partner information
|
||||
partner_first_name: Optional[str] = None
|
||||
partner_last_name: Optional[str] = None
|
||||
partner_is_member: Optional[bool] = None
|
||||
partner_plan_to_become_member: Optional[bool] = None
|
||||
|
||||
# Newsletter preferences
|
||||
newsletter_publish_name: Optional[bool] = None
|
||||
newsletter_publish_photo: Optional[bool] = None
|
||||
newsletter_publish_birthday: Optional[bool] = None
|
||||
newsletter_publish_none: Optional[bool] = None
|
||||
|
||||
# Volunteer interests (array of strings)
|
||||
volunteer_interests: Optional[list] = None
|
||||
|
||||
# Directory settings
|
||||
show_in_directory: Optional[bool] = None
|
||||
directory_email: Optional[str] = None
|
||||
directory_bio: Optional[str] = None
|
||||
directory_address: Optional[str] = None
|
||||
directory_phone: Optional[str] = None
|
||||
directory_dob: Optional[datetime] = None
|
||||
directory_partner_name: Optional[str] = None
|
||||
|
||||
@validator('directory_dob', pre=True)
|
||||
def empty_str_to_none(cls, v):
|
||||
"""Convert empty string to None for optional datetime field"""
|
||||
if v == '' or v is None:
|
||||
return None
|
||||
return v
|
||||
|
||||
class EnhancedProfileUpdateRequest(BaseModel):
|
||||
"""Members Only - Enhanced profile update with social media and directory settings"""
|
||||
social_media_facebook: Optional[str] = None
|
||||
@@ -199,6 +258,13 @@ class EnhancedProfileUpdateRequest(BaseModel):
|
||||
directory_dob: Optional[datetime] = None
|
||||
directory_partner_name: Optional[str] = None
|
||||
|
||||
@validator('directory_dob', pre=True)
|
||||
def empty_str_to_none(cls, v):
|
||||
"""Convert empty string to None for optional datetime field"""
|
||||
if v == '' or v is None:
|
||||
return None
|
||||
return v
|
||||
|
||||
class CalendarEventResponse(BaseModel):
|
||||
"""Calendar view response with user RSVP status"""
|
||||
id: str
|
||||
@@ -261,14 +327,21 @@ class UpdateUserStatusRequest(BaseModel):
|
||||
|
||||
class ManualPaymentRequest(BaseModel):
|
||||
plan_id: str = Field(..., description="Subscription plan ID")
|
||||
amount_cents: int = Field(..., description="Payment amount in cents")
|
||||
amount_cents: int = Field(..., ge=3000, description="Payment amount in cents (minimum $30)")
|
||||
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")
|
||||
override_plan_dates: bool = Field(False, description="Override plan's custom billing cycle with admin-specified dates")
|
||||
notes: Optional[str] = Field(None, description="Admin notes about payment")
|
||||
|
||||
@validator('amount_cents')
|
||||
def validate_amount(cls, v):
|
||||
if v < 3000:
|
||||
raise ValueError('Amount must be at least $30 (3000 cents)')
|
||||
return v
|
||||
|
||||
# Auth Routes
|
||||
@api_router.post("/auth/register")
|
||||
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||
@@ -520,22 +593,8 @@ async def get_me(current_user: User = Depends(get_current_user), db: Session = D
|
||||
# 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
|
||||
)
|
||||
# Use from_attributes to automatically map all User fields to UserResponse
|
||||
return UserResponse.model_validate(current_user)
|
||||
|
||||
@api_router.put("/users/profile")
|
||||
async def update_profile(
|
||||
@@ -543,21 +602,64 @@ async def update_profile(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if request.first_name:
|
||||
"""Update user profile with basic info, partner details, newsletter prefs, volunteer interests, and directory settings."""
|
||||
|
||||
# Basic personal information
|
||||
if request.first_name is not None:
|
||||
current_user.first_name = request.first_name
|
||||
if request.last_name:
|
||||
if request.last_name is not None:
|
||||
current_user.last_name = request.last_name
|
||||
if request.phone:
|
||||
if request.phone is not None:
|
||||
current_user.phone = request.phone
|
||||
if request.address:
|
||||
if request.address is not None:
|
||||
current_user.address = request.address
|
||||
if request.city:
|
||||
if request.city is not None:
|
||||
current_user.city = request.city
|
||||
if request.state:
|
||||
if request.state is not None:
|
||||
current_user.state = request.state
|
||||
if request.zipcode:
|
||||
if request.zipcode is not None:
|
||||
current_user.zipcode = request.zipcode
|
||||
|
||||
# Partner information
|
||||
if request.partner_first_name is not None:
|
||||
current_user.partner_first_name = request.partner_first_name
|
||||
if request.partner_last_name is not None:
|
||||
current_user.partner_last_name = request.partner_last_name
|
||||
if request.partner_is_member is not None:
|
||||
current_user.partner_is_member = request.partner_is_member
|
||||
if request.partner_plan_to_become_member is not None:
|
||||
current_user.partner_plan_to_become_member = request.partner_plan_to_become_member
|
||||
|
||||
# Newsletter preferences
|
||||
if request.newsletter_publish_name is not None:
|
||||
current_user.newsletter_publish_name = request.newsletter_publish_name
|
||||
if request.newsletter_publish_photo is not None:
|
||||
current_user.newsletter_publish_photo = request.newsletter_publish_photo
|
||||
if request.newsletter_publish_birthday is not None:
|
||||
current_user.newsletter_publish_birthday = request.newsletter_publish_birthday
|
||||
if request.newsletter_publish_none is not None:
|
||||
current_user.newsletter_publish_none = request.newsletter_publish_none
|
||||
|
||||
# Volunteer interests (array)
|
||||
if request.volunteer_interests is not None:
|
||||
current_user.volunteer_interests = request.volunteer_interests
|
||||
|
||||
# Directory settings
|
||||
if request.show_in_directory is not None:
|
||||
current_user.show_in_directory = request.show_in_directory
|
||||
if request.directory_email is not None:
|
||||
current_user.directory_email = request.directory_email
|
||||
if request.directory_bio is not None:
|
||||
current_user.directory_bio = request.directory_bio
|
||||
if request.directory_address is not None:
|
||||
current_user.directory_address = request.directory_address
|
||||
if request.directory_phone is not None:
|
||||
current_user.directory_phone = request.directory_phone
|
||||
if request.directory_dob is not None:
|
||||
current_user.directory_dob = request.directory_dob
|
||||
if request.directory_partner_name is not None:
|
||||
current_user.directory_partner_name = request.directory_partner_name
|
||||
|
||||
current_user.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
@@ -567,6 +669,72 @@ async def update_profile(
|
||||
|
||||
# ==================== MEMBERS ONLY ROUTES ====================
|
||||
|
||||
# Member Directory Routes
|
||||
@api_router.get("/members/directory")
|
||||
async def get_member_directory(
|
||||
current_user: User = Depends(get_active_member),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get list of all members who opted into the directory"""
|
||||
directory_members = db.query(User).filter(
|
||||
User.show_in_directory == True,
|
||||
User.role == UserRole.member,
|
||||
User.status == UserStatus.active
|
||||
).all()
|
||||
|
||||
return [{
|
||||
"id": str(member.id),
|
||||
"first_name": member.first_name,
|
||||
"last_name": member.last_name,
|
||||
"profile_photo_url": member.profile_photo_url,
|
||||
"directory_email": member.directory_email,
|
||||
"directory_bio": member.directory_bio,
|
||||
"directory_address": member.directory_address,
|
||||
"directory_phone": member.directory_phone,
|
||||
"directory_dob": member.directory_dob,
|
||||
"directory_partner_name": member.directory_partner_name,
|
||||
"volunteer_interests": member.volunteer_interests or [],
|
||||
"social_media_facebook": member.social_media_facebook,
|
||||
"social_media_instagram": member.social_media_instagram,
|
||||
"social_media_twitter": member.social_media_twitter,
|
||||
"social_media_linkedin": member.social_media_linkedin
|
||||
} for member in directory_members]
|
||||
|
||||
@api_router.get("/members/directory/{user_id}")
|
||||
async def get_directory_member_profile(
|
||||
user_id: str,
|
||||
current_user: User = Depends(get_active_member),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Get public directory profile of a specific member"""
|
||||
member = db.query(User).filter(
|
||||
User.id == user_id,
|
||||
User.show_in_directory == True,
|
||||
User.role == UserRole.member,
|
||||
User.status == UserStatus.active
|
||||
).first()
|
||||
|
||||
if not member:
|
||||
raise HTTPException(status_code=404, detail="Member not found in directory")
|
||||
|
||||
return {
|
||||
"id": str(member.id),
|
||||
"first_name": member.first_name,
|
||||
"last_name": member.last_name,
|
||||
"profile_photo_url": member.profile_photo_url,
|
||||
"directory_email": member.directory_email,
|
||||
"directory_bio": member.directory_bio,
|
||||
"directory_address": member.directory_address,
|
||||
"directory_phone": member.directory_phone,
|
||||
"directory_dob": member.directory_dob,
|
||||
"directory_partner_name": member.directory_partner_name,
|
||||
"volunteer_interests": member.volunteer_interests or [],
|
||||
"social_media_facebook": member.social_media_facebook,
|
||||
"social_media_instagram": member.social_media_instagram,
|
||||
"social_media_twitter": member.social_media_twitter,
|
||||
"social_media_linkedin": member.social_media_linkedin
|
||||
}
|
||||
|
||||
# Enhanced Profile Routes (Active Members Only)
|
||||
@api_router.get("/members/profile")
|
||||
async def get_enhanced_profile(
|
||||
@@ -1717,31 +1885,34 @@ async def activate_payment_manually(
|
||||
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
|
||||
# 4. Validate amount against plan minimum
|
||||
if request.amount_cents < plan.minimum_price_cents:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}"
|
||||
)
|
||||
|
||||
# 5. Calculate donation split
|
||||
base_amount = plan.minimum_price_cents
|
||||
donation_amount = request.amount_cents - base_amount
|
||||
|
||||
# 6. Calculate subscription period
|
||||
from payment_service import calculate_subscription_period
|
||||
|
||||
if request.use_custom_period or request.override_plan_dates:
|
||||
# Admin-specified custom dates override everything
|
||||
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"
|
||||
detail="Custom period start and end dates are required when use_custom_period or override_plan_dates 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
|
||||
# Use plan's custom cycle or billing cycle
|
||||
period_start, period_end = calculate_subscription_period(plan)
|
||||
|
||||
# 5. Create subscription record (manual payment)
|
||||
# 7. Create subscription record (manual payment) with donation tracking
|
||||
subscription = Subscription(
|
||||
user_id=user.id,
|
||||
plan_id=plan.id,
|
||||
@@ -1751,6 +1922,8 @@ async def activate_payment_manually(
|
||||
start_date=period_start,
|
||||
end_date=period_end,
|
||||
amount_paid_cents=request.amount_cents,
|
||||
base_subscription_cents=base_amount,
|
||||
donation_cents=donation_amount,
|
||||
payment_method=request.payment_method,
|
||||
manual_payment=True,
|
||||
manual_payment_notes=request.notes,
|
||||
@@ -2031,22 +2204,60 @@ async def delete_event(
|
||||
# Pydantic model for checkout request
|
||||
class CheckoutRequest(BaseModel):
|
||||
plan_id: str
|
||||
amount_cents: int = Field(..., ge=3000, description="Total amount in cents (minimum $30)")
|
||||
|
||||
@validator('amount_cents')
|
||||
def validate_amount(cls, v):
|
||||
if v < 3000:
|
||||
raise ValueError('Amount must be at least $30 (3000 cents)')
|
||||
return v
|
||||
|
||||
# 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
|
||||
price_cents: int = Field(ge=0, le=100000000) # Legacy field, kept for backward compatibility
|
||||
billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime", "custom"]
|
||||
stripe_price_id: Optional[str] = None # Deprecated, no longer required
|
||||
active: bool = True
|
||||
|
||||
# Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31)
|
||||
custom_cycle_enabled: bool = False
|
||||
custom_cycle_start_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
custom_cycle_start_day: Optional[int] = Field(None, ge=1, le=31)
|
||||
custom_cycle_end_month: Optional[int] = Field(None, ge=1, le=12)
|
||||
custom_cycle_end_day: Optional[int] = Field(None, ge=1, le=31)
|
||||
|
||||
# Dynamic pricing fields
|
||||
minimum_price_cents: int = Field(3000, ge=3000, le=100000000) # $30 minimum
|
||||
suggested_price_cents: Optional[int] = Field(None, ge=3000, le=100000000)
|
||||
allow_donation: bool = True
|
||||
|
||||
@validator('name')
|
||||
def validate_name(cls, v):
|
||||
if not v.strip():
|
||||
raise ValueError('Name cannot be empty or whitespace')
|
||||
return v.strip()
|
||||
|
||||
@validator('custom_cycle_start_month', 'custom_cycle_end_month')
|
||||
def validate_months(cls, v):
|
||||
if v is not None and (v < 1 or v > 12):
|
||||
raise ValueError('Month must be between 1 and 12')
|
||||
return v
|
||||
|
||||
@validator('custom_cycle_start_day', 'custom_cycle_end_day')
|
||||
def validate_days(cls, v):
|
||||
if v is not None and (v < 1 or v > 31):
|
||||
raise ValueError('Day must be between 1 and 31')
|
||||
return v
|
||||
|
||||
@validator('suggested_price_cents')
|
||||
def validate_suggested_price(cls, v, values):
|
||||
if v is not None and 'minimum_price_cents' in values:
|
||||
if v < values['minimum_price_cents']:
|
||||
raise ValueError('Suggested price must be >= minimum price')
|
||||
return v
|
||||
|
||||
@api_router.get("/subscriptions/plans")
|
||||
async def get_subscription_plans(db: Session = Depends(get_db)):
|
||||
"""Get all active subscription plans."""
|
||||
@@ -2132,13 +2343,36 @@ async def create_plan(
|
||||
detail="A plan with this name already exists"
|
||||
)
|
||||
|
||||
# Validate custom cycle dates if enabled
|
||||
if request.custom_cycle_enabled:
|
||||
if not all([
|
||||
request.custom_cycle_start_month,
|
||||
request.custom_cycle_start_day,
|
||||
request.custom_cycle_end_month,
|
||||
request.custom_cycle_end_day
|
||||
]):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="All custom cycle date fields must be provided when custom_cycle_enabled is true"
|
||||
)
|
||||
|
||||
plan = SubscriptionPlan(
|
||||
name=request.name,
|
||||
description=request.description,
|
||||
price_cents=request.price_cents,
|
||||
price_cents=request.price_cents, # Legacy field
|
||||
billing_cycle=request.billing_cycle,
|
||||
stripe_price_id=request.stripe_price_id,
|
||||
active=request.active
|
||||
stripe_price_id=request.stripe_price_id, # Deprecated
|
||||
active=request.active,
|
||||
# Custom billing cycle fields
|
||||
custom_cycle_enabled=request.custom_cycle_enabled,
|
||||
custom_cycle_start_month=request.custom_cycle_start_month,
|
||||
custom_cycle_start_day=request.custom_cycle_start_day,
|
||||
custom_cycle_end_month=request.custom_cycle_end_month,
|
||||
custom_cycle_end_day=request.custom_cycle_end_day,
|
||||
# Dynamic pricing fields
|
||||
minimum_price_cents=request.minimum_price_cents,
|
||||
suggested_price_cents=request.suggested_price_cents,
|
||||
allow_donation=request.allow_donation
|
||||
)
|
||||
|
||||
db.add(plan)
|
||||
@@ -2155,6 +2389,14 @@ async def create_plan(
|
||||
"billing_cycle": plan.billing_cycle,
|
||||
"stripe_price_id": plan.stripe_price_id,
|
||||
"active": plan.active,
|
||||
"custom_cycle_enabled": plan.custom_cycle_enabled,
|
||||
"custom_cycle_start_month": plan.custom_cycle_start_month,
|
||||
"custom_cycle_start_day": plan.custom_cycle_start_day,
|
||||
"custom_cycle_end_month": plan.custom_cycle_end_month,
|
||||
"custom_cycle_end_day": plan.custom_cycle_end_day,
|
||||
"minimum_price_cents": plan.minimum_price_cents,
|
||||
"suggested_price_cents": plan.suggested_price_cents,
|
||||
"allow_donation": plan.allow_donation,
|
||||
"subscriber_count": 0,
|
||||
"created_at": plan.created_at,
|
||||
"updated_at": plan.updated_at
|
||||
@@ -2184,13 +2426,36 @@ async def update_plan(
|
||||
detail="A plan with this name already exists"
|
||||
)
|
||||
|
||||
# Validate custom cycle dates if enabled
|
||||
if request.custom_cycle_enabled:
|
||||
if not all([
|
||||
request.custom_cycle_start_month,
|
||||
request.custom_cycle_start_day,
|
||||
request.custom_cycle_end_month,
|
||||
request.custom_cycle_end_day
|
||||
]):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="All custom cycle date fields must be provided when custom_cycle_enabled is true"
|
||||
)
|
||||
|
||||
# Update fields
|
||||
plan.name = request.name
|
||||
plan.description = request.description
|
||||
plan.price_cents = request.price_cents
|
||||
plan.price_cents = request.price_cents # Legacy field
|
||||
plan.billing_cycle = request.billing_cycle
|
||||
plan.stripe_price_id = request.stripe_price_id
|
||||
plan.stripe_price_id = request.stripe_price_id # Deprecated
|
||||
plan.active = request.active
|
||||
# Custom billing cycle fields
|
||||
plan.custom_cycle_enabled = request.custom_cycle_enabled
|
||||
plan.custom_cycle_start_month = request.custom_cycle_start_month
|
||||
plan.custom_cycle_start_day = request.custom_cycle_start_day
|
||||
plan.custom_cycle_end_month = request.custom_cycle_end_month
|
||||
plan.custom_cycle_end_day = request.custom_cycle_end_day
|
||||
# Dynamic pricing fields
|
||||
plan.minimum_price_cents = request.minimum_price_cents
|
||||
plan.suggested_price_cents = request.suggested_price_cents
|
||||
plan.allow_donation = request.allow_donation
|
||||
plan.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
db.commit()
|
||||
@@ -2710,7 +2975,7 @@ async def create_checkout(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Create Stripe Checkout session for subscription payment."""
|
||||
"""Create Stripe Checkout session with dynamic pricing and donation tracking."""
|
||||
|
||||
# Get plan
|
||||
plan = db.query(SubscriptionPlan).filter(
|
||||
@@ -2723,24 +2988,110 @@ async def create_checkout(
|
||||
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")
|
||||
# Validate amount against plan minimum
|
||||
if request.amount_cents < plan.minimum_price_cents:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}"
|
||||
)
|
||||
|
||||
# Calculate donation split
|
||||
base_amount = plan.minimum_price_cents
|
||||
donation_amount = request.amount_cents - base_amount
|
||||
|
||||
# Check if plan allows donations
|
||||
if donation_amount > 0 and not plan.allow_donation:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="This plan does not accept donations above the minimum price"
|
||||
)
|
||||
|
||||
# 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,
|
||||
# Build line items for Stripe checkout
|
||||
line_items = []
|
||||
|
||||
# Add base subscription line item with dynamic pricing
|
||||
from payment_service import get_stripe_interval
|
||||
stripe_interval = get_stripe_interval(plan.billing_cycle)
|
||||
|
||||
if stripe_interval: # Recurring subscription
|
||||
line_items.append({
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"unit_amount": base_amount,
|
||||
"recurring": {"interval": stripe_interval},
|
||||
"product_data": {
|
||||
"name": plan.name,
|
||||
"description": plan.description or f"{plan.name} membership"
|
||||
}
|
||||
},
|
||||
"quantity": 1
|
||||
})
|
||||
else: # One-time payment (lifetime)
|
||||
line_items.append({
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"unit_amount": base_amount,
|
||||
"product_data": {
|
||||
"name": plan.name,
|
||||
"description": plan.description or f"{plan.name} membership"
|
||||
}
|
||||
},
|
||||
"quantity": 1
|
||||
})
|
||||
|
||||
# Add donation line item if applicable
|
||||
if donation_amount > 0:
|
||||
line_items.append({
|
||||
"price_data": {
|
||||
"currency": "usd",
|
||||
"unit_amount": donation_amount,
|
||||
"product_data": {
|
||||
"name": "Donation",
|
||||
"description": f"Additional donation to support {plan.name}"
|
||||
}
|
||||
},
|
||||
"quantity": 1
|
||||
})
|
||||
|
||||
# Create Stripe Checkout Session
|
||||
import stripe
|
||||
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||
|
||||
mode = "subscription" if stripe_interval else "payment"
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
customer_email=current_user.email,
|
||||
payment_method_types=["card"],
|
||||
line_items=line_items,
|
||||
mode=mode,
|
||||
success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{frontend_url}/payment-cancel"
|
||||
cancel_url=f"{frontend_url}/payment-cancel",
|
||||
metadata={
|
||||
"user_id": str(current_user.id),
|
||||
"plan_id": str(plan.id),
|
||||
"base_amount": str(base_amount),
|
||||
"donation_amount": str(donation_amount),
|
||||
"total_amount": str(request.amount_cents)
|
||||
},
|
||||
subscription_data={
|
||||
"metadata": {
|
||||
"user_id": str(current_user.id),
|
||||
"plan_id": str(plan.id),
|
||||
"base_amount": str(base_amount),
|
||||
"donation_amount": str(donation_amount)
|
||||
}
|
||||
} if mode == "subscription" else None
|
||||
)
|
||||
|
||||
return {"checkout_url": session["url"]}
|
||||
return {"checkout_url": session.url}
|
||||
|
||||
except stripe.error.StripeError as e:
|
||||
logger.error(f"Stripe error creating checkout session: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating checkout session: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Failed to create checkout session")
|
||||
@@ -2770,6 +3121,9 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
||||
# 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")
|
||||
@@ -2786,16 +3140,23 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
||||
).first()
|
||||
|
||||
if not existing_subscription:
|
||||
# Create subscription record
|
||||
# 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=datetime.now(timezone.utc),
|
||||
end_date=get_subscription_end_date(plan.billing_cycle),
|
||||
amount_paid_cents=session.get("amount_total", plan.price_cents)
|
||||
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)
|
||||
|
||||
@@ -2806,7 +3167,10 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(f"Subscription created for user {user.email}")
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user