From 834d65ec49b721cfe1cc14b93c99e2e5c1a0fd7f Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Thu, 11 Dec 2025 23:14:13 +0700 Subject: [PATCH] Donation page update and Subscription update on Admin Dashboard --- __pycache__/server.cpython-312.pyc | Bin 139088 -> 149643 bytes server.py | 217 +++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 4c1f9bd042ba4c0defb3e7527e44fb437ab4cb78..04465dce429fe7cdbf5d4fea2d4171e2a2a26034 100644 GIT binary patch delta 11229 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 delta 4473 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 Y^p?Lwsq$AEj^y`}pgy44c01YlFZXh9>i_@% 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."""