diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 4c1f9bd..04465dc 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/server.py b/server.py index fb5a69d..eb0fcc9 100644 --- a/server.py +++ b/server.py @@ -2265,6 +2265,21 @@ class PlanCreateRequest(BaseModel): raise ValueError('Suggested price must be >= minimum price') return v +# Pydantic model for updating subscriptions +class UpdateSubscriptionRequest(BaseModel): + status: Optional[str] = Field(None, pattern="^(active|expired|cancelled)$") + end_date: Optional[datetime] = None + +# Pydantic model for donation checkout +class DonationCheckoutRequest(BaseModel): + amount_cents: int = Field(..., ge=100, description="Donation amount in cents (minimum $1.00)") + + @validator('amount_cents') + def validate_amount(cls, v): + if v < 100: + raise ValueError('Donation must be at least $1.00 (100 cents)') + return v + @api_router.get("/subscriptions/plans") async def get_subscription_plans(db: Session = Depends(get_db)): """Get all active subscription plans.""" @@ -2520,6 +2535,155 @@ async def delete_plan( return {"message": "Plan deactivated successfully"} +# ============================================================================ +# Admin Subscription Management Routes +# ============================================================================ + +@api_router.get("/admin/subscriptions") +async def get_all_subscriptions( + status: Optional[str] = None, + plan_id: Optional[str] = None, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Get all subscriptions with optional filters.""" + # Use explicit join to avoid ambiguous foreign key error + query = db.query(Subscription).join(Subscription.user).join(Subscription.plan) + + if status: + query = query.filter(Subscription.status == status) + if plan_id: + query = query.filter(Subscription.plan_id == plan_id) + + subscriptions = query.order_by(Subscription.created_at.desc()).all() + + return [{ + "id": str(sub.id), + "user": { + "id": str(sub.user.id), + "first_name": sub.user.first_name, + "last_name": sub.user.last_name, + "email": sub.user.email + }, + "plan": { + "id": str(sub.plan.id), + "name": sub.plan.name, + "billing_cycle": sub.plan.billing_cycle + }, + "status": sub.status.value, + "start_date": sub.start_date, + "end_date": sub.end_date, + "amount_paid_cents": sub.amount_paid_cents, + "base_subscription_cents": sub.base_subscription_cents, + "donation_cents": sub.donation_cents, + "payment_method": sub.payment_method, + "stripe_subscription_id": sub.stripe_subscription_id, + "created_at": sub.created_at, + "updated_at": sub.updated_at + } for sub in subscriptions] + +@api_router.get("/admin/subscriptions/stats") +async def get_subscription_stats( + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Get subscription statistics for admin dashboard.""" + from sqlalchemy import func + + total = db.query(Subscription).count() + active = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.active + ).count() + cancelled = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.cancelled + ).count() + expired = db.query(Subscription).filter( + Subscription.status == SubscriptionStatus.expired + ).count() + + revenue_data = db.query( + func.sum(Subscription.amount_paid_cents).label('total_revenue'), + func.sum(Subscription.base_subscription_cents).label('total_base'), + func.sum(Subscription.donation_cents).label('total_donations') + ).first() + + return { + "total": total, + "active": active, + "cancelled": cancelled, + "expired": expired, + "total_revenue": revenue_data.total_revenue or 0, + "total_base": revenue_data.total_base or 0, + "total_donations": revenue_data.total_donations or 0 + } + +@api_router.put("/admin/subscriptions/{subscription_id}") +async def update_subscription( + subscription_id: str, + request: UpdateSubscriptionRequest, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Update subscription details (status, dates).""" + subscription = db.query(Subscription).filter( + Subscription.id == subscription_id + ).first() + + if not subscription: + raise HTTPException(status_code=404, detail="Subscription not found") + + # Update fields if provided + if request.status: + subscription.status = SubscriptionStatus[request.status] + if request.end_date: + subscription.end_date = request.end_date + + subscription.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(subscription) + + logger.info(f"Admin {current_user.email} updated subscription {subscription_id}") + + return { + "id": str(subscription.id), + "user_id": str(subscription.user_id), + "plan_id": str(subscription.plan_id), + "status": subscription.status.value, + "start_date": subscription.start_date, + "end_date": subscription.end_date, + "amount_paid_cents": subscription.amount_paid_cents, + "updated_at": subscription.updated_at + } + +@api_router.post("/admin/subscriptions/{subscription_id}/cancel") +async def cancel_subscription( + subscription_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Cancel a subscription.""" + subscription = db.query(Subscription).filter( + Subscription.id == subscription_id + ).first() + + if not subscription: + raise HTTPException(status_code=404, detail="Subscription not found") + + subscription.status = SubscriptionStatus.cancelled + subscription.updated_at = datetime.now(timezone.utc) + + # Also update user status if currently active + user = subscription.user + if user.status == UserStatus.active: + user.status = UserStatus.inactive + user.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Admin {current_user.email} cancelled subscription {subscription_id} for user {user.email}") + + return {"message": "Subscription cancelled successfully"} + # ============================================================================ # Admin Document Management Routes # ============================================================================ @@ -2984,6 +3148,15 @@ async def create_checkout( ): """Create Stripe Checkout session with dynamic pricing and donation tracking.""" + # Status validation - only allow payment_pending and inactive users + allowed_statuses = [UserStatus.payment_pending, UserStatus.inactive] + if current_user.status not in allowed_statuses: + raise HTTPException( + status_code=403, + detail=f"Cannot proceed with payment. User status is '{current_user.status.value}'. " + f"Please complete email verification and admin approval first." + ) + # Get plan plan = db.query(SubscriptionPlan).filter( SubscriptionPlan.id == request.plan_id @@ -3103,6 +3276,50 @@ async def create_checkout( logger.error(f"Error creating checkout session: {str(e)}") raise HTTPException(status_code=500, detail="Failed to create checkout session") +@api_router.post("/donations/checkout") +async def create_donation_checkout( + request: DonationCheckoutRequest, + db: Session = Depends(get_db) +): + """Create Stripe Checkout session for one-time donation.""" + + # Get frontend URL from env + frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + + try: + # Create Stripe Checkout Session for one-time payment + import stripe + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + + checkout_session = stripe.checkout.Session.create( + payment_method_types=['card'], + line_items=[{ + 'price_data': { + 'currency': 'usd', + 'unit_amount': request.amount_cents, + 'product_data': { + 'name': 'Donation to LOAF', + 'description': 'Thank you for supporting our community!' + } + }, + 'quantity': 1 + }], + 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" + ) + + logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f}") + + return {"checkout_url": checkout_session.url} + + except stripe.error.StripeError as e: + 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: + logger.error(f"Error creating donation checkout: {str(e)}") + raise HTTPException(status_code=500, detail="Failed to create donation checkout") + @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."""