commit 6ef7685ade5f3d5f81da4931a00e5b32a6dcd6ea Author: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Fri Dec 5 16:43:37 2025 +0700 first commit diff --git a/.env b/.env new file mode 100644 index 0000000..dc5dc63 --- /dev/null +++ b/.env @@ -0,0 +1,14 @@ +DATABASE_URL=postgresql://postgres:RchhcpaUKZuZuMOvB5kwCP1weLBnAG6tNMXE5FHdk8AwCvolBMALYFVYRM7WCl9x@10.9.23.11:5001/loaf +CORS_ORIGINS=* +JWT_SECRET=your-secret-key-change-this-in-production +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USERNAME=your-email@gmail.com +SMTP_PASSWORD=your-app-password +SMTP_FROM_EMAIL=noreply@membership.com +SMTP_FROM_NAME=Membership Platform +FRONTEND_URL=http://localhost:3000 +STRIPE_SECRET_KEY=sk_test_51RshYMLFfdCcyjTAYXXcFpH650EzUvUeN98nEa4Kj4EUqBpgdrIFsAHClbdn9lBnyWS54dHLPkUdQhlyxqEQKRds00TJYjpKnA +STRIPE_WEBHOOK_SECRET=whsec_78319c49ffe749cf62144160dc114e7523519d31432b062ef71423023ae7bbfa \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/__pycache__/auth.cpython-311.pyc b/__pycache__/auth.cpython-311.pyc new file mode 100644 index 0000000..0d252ac Binary files /dev/null and b/__pycache__/auth.cpython-311.pyc differ diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000..bc7dcd3 Binary files /dev/null and b/__pycache__/auth.cpython-312.pyc differ diff --git a/__pycache__/database.cpython-311.pyc b/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000..238df63 Binary files /dev/null and b/__pycache__/database.cpython-311.pyc differ diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc new file mode 100644 index 0000000..0234c73 Binary files /dev/null and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/email_service.cpython-311.pyc b/__pycache__/email_service.cpython-311.pyc new file mode 100644 index 0000000..b0892a6 Binary files /dev/null and b/__pycache__/email_service.cpython-311.pyc differ diff --git a/__pycache__/email_service.cpython-312.pyc b/__pycache__/email_service.cpython-312.pyc new file mode 100644 index 0000000..6296597 Binary files /dev/null and b/__pycache__/email_service.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-311.pyc b/__pycache__/models.cpython-311.pyc new file mode 100644 index 0000000..b652cdc Binary files /dev/null and b/__pycache__/models.cpython-311.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..5bbab35 Binary files /dev/null and b/__pycache__/models.cpython-312.pyc differ diff --git a/__pycache__/payment_service.cpython-312.pyc b/__pycache__/payment_service.cpython-312.pyc new file mode 100644 index 0000000..facceeb Binary files /dev/null and b/__pycache__/payment_service.cpython-312.pyc differ diff --git a/__pycache__/server.cpython-311.pyc b/__pycache__/server.cpython-311.pyc new file mode 100644 index 0000000..f0bb6cf Binary files /dev/null and b/__pycache__/server.cpython-311.pyc differ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000..3e2abfd Binary files /dev/null and b/__pycache__/server.cpython-312.pyc differ diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..ad38cdf --- /dev/null +++ b/auth.py @@ -0,0 +1,80 @@ +from datetime import datetime, timedelta, timezone +from typing import Optional +from jose import JWTError, jwt +from passlib.context import CryptContext +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +import os +from database import get_db +from models import User, UserRole + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") +security = HTTPBearer() + +JWT_SECRET = os.environ.get('JWT_SECRET', 'your-secret-key') +JWT_ALGORITHM = os.environ.get('JWT_ALGORITHM', 'HS256') +ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRE_MINUTES', 30)) + +def verify_password(plain_password, hashed_password): + return pwd_context.verify(plain_password, hashed_password) + +def get_password_hash(password): + return pwd_context.hash(password) + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None): + to_encode = data.copy() + if expires_delta: + expire = datetime.now(timezone.utc) + expires_delta + else: + expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, JWT_SECRET, algorithm=JWT_ALGORITHM) + return encoded_jwt + +def decode_token(token: str): + try: + payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + return payload + except JWTError: + return None + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: Session = Depends(get_db) +) -> User: + token = credentials.credentials + payload = decode_token(token) + + if payload is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user_id: str = payload.get("sub") + if user_id is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + + user = db.query(User).filter(User.id == user_id).first() + if user is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="User not found", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return user + +async def get_current_admin_user(current_user: User = Depends(get_current_user)) -> User: + if current_user.role != UserRole.admin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Not enough permissions" + ) + return current_user diff --git a/create_admin.py b/create_admin.py new file mode 100644 index 0000000..76463a3 --- /dev/null +++ b/create_admin.py @@ -0,0 +1,73 @@ +""" +Create an admin user for testing. +Run this script to add an admin account to your database. +""" + +from database import SessionLocal +from models import User, UserStatus, UserRole +from auth import get_password_hash +from datetime import datetime, timezone + +def create_admin(): + """Create an admin user""" + db = SessionLocal() + + try: + # Check if admin already exists + existing_admin = db.query(User).filter( + User.email == "admin@loaf.org" + ).first() + + if existing_admin: + print(f"⚠️ Admin user already exists: {existing_admin.email}") + print(f" Role: {existing_admin.role.value}") + print(f" Status: {existing_admin.status.value}") + + # Update to admin role if not already + if existing_admin.role != UserRole.admin: + existing_admin.role = UserRole.admin + existing_admin.status = UserStatus.active + existing_admin.email_verified = True + db.commit() + print("✅ Updated existing user to admin role") + return + + print("Creating admin user...") + + # Create admin user + admin_user = User( + email="admin@loaf.org", + password_hash=get_password_hash("admin123"), # Change this password! + first_name="Admin", + last_name="User", + phone="555-0001", + address="123 Admin Street", + city="Admin City", + state="CA", + zipcode="90001", + date_of_birth=datetime(1990, 1, 1), + status=UserStatus.active, + role=UserRole.admin, + email_verified=True, + newsletter_subscribed=False + ) + + db.add(admin_user) + db.commit() + db.refresh(admin_user) + + print("✅ Admin user created successfully!") + print(f" Email: admin@loaf.org") + print(f" Password: admin123") + print(f" Role: {admin_user.role.value}") + print(f" User ID: {admin_user.id}") + print("\n⚠️ IMPORTANT: Change the password after first login!") + + except Exception as e: + print(f"❌ Error creating admin user: {e}") + db.rollback() + finally: + db.close() + +if __name__ == "__main__": + create_admin() diff --git a/database.py b/database.py new file mode 100644 index 0000000..9a048a2 --- /dev/null +++ b/database.py @@ -0,0 +1,23 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker +import os +from dotenv import load_dotenv +from pathlib import Path + +ROOT_DIR = Path(__file__).parent +load_dotenv(ROOT_DIR / '.env') + +DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://user:password@localhost:5432/membership_db') + +engine = create_engine(DATABASE_URL) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/email_service.py b/email_service.py new file mode 100644 index 0000000..05c7c33 --- /dev/null +++ b/email_service.py @@ -0,0 +1,182 @@ +import os +from email.mime.text import MIMEText +from email.mime.multipart import MIMEMultipart +import aiosmtplib +import logging + +logger = logging.getLogger(__name__) + +SMTP_HOST = os.environ.get('SMTP_HOST', 'smtp.gmail.com') +SMTP_PORT = int(os.environ.get('SMTP_PORT', 587)) +SMTP_USERNAME = os.environ.get('SMTP_USERNAME', '') +SMTP_PASSWORD = os.environ.get('SMTP_PASSWORD', '') +SMTP_FROM_EMAIL = os.environ.get('SMTP_FROM_EMAIL', 'noreply@membership.com') +SMTP_FROM_NAME = os.environ.get('SMTP_FROM_NAME', 'Membership Platform') +FRONTEND_URL = os.environ.get('FRONTEND_URL', 'http://localhost:3000') + +async def send_email(to_email: str, subject: str, html_content: str): + """Send an email using SMTP""" + try: + message = MIMEMultipart('alternative') + message['From'] = f"{SMTP_FROM_NAME} <{SMTP_FROM_EMAIL}>" + message['To'] = to_email + message['Subject'] = subject + + html_part = MIMEText(html_content, 'html') + message.attach(html_part) + + # For development/testing, just log the email + if not SMTP_USERNAME or not SMTP_PASSWORD: + logger.info(f"[EMAIL] To: {to_email}") + logger.info(f"[EMAIL] Subject: {subject}") + logger.info(f"[EMAIL] Content: {html_content}") + return True + + # Send actual email + await aiosmtplib.send( + message, + hostname=SMTP_HOST, + port=SMTP_PORT, + username=SMTP_USERNAME, + password=SMTP_PASSWORD, + start_tls=True + ) + logger.info(f"Email sent successfully to {to_email}") + return True + except Exception as e: + logger.error(f"Failed to send email to {to_email}: {str(e)}") + return False + +async def send_verification_email(to_email: str, token: str): + """Send email verification link""" + verification_url = f"{FRONTEND_URL}/verify-email?token={token}" + subject = "Verify Your Email Address" + html_content = f""" + + + + + + +
+
+

Welcome to Our Community!

+
+
+

Thank you for registering with us. We're excited to have you join our community.

+

Please click the button below to verify your email address:

+

+ Verify Email +

+

Or copy and paste this link into your browser:

+

{verification_url}

+

This link will expire in 24 hours.

+

If you didn't create an account, please ignore this email.

+
+
+ + + """ + return await send_email(to_email, subject, html_content) + +async def send_approval_notification(to_email: str, first_name: str): + """Send notification when user is approved""" + login_url = f"{FRONTEND_URL}/login" + subject = "Your Membership Application Has Been Approved!" + html_content = f""" + + + + + + +
+
+

Congratulations, {first_name}!

+
+
+

Great news! Your membership application has been approved.

+

You now have full access to all member features and events.

+

+ Login to Your Account +

+

We look forward to seeing you at our events!

+
+
+ + + """ + return await send_email(to_email, subject, html_content) + +async def send_payment_prompt_email(to_email: str, first_name: str): + """Send payment prompt email after admin approval""" + payment_url = f"{FRONTEND_URL}/plans" + subject = "Complete Your LOAF Membership - Payment Required" + html_content = f""" + + + + + + +
+
+

🎉 You're Approved, {first_name}!

+
+
+

Great news! Your LOAF membership application has been approved by our admin team.

+ +

To activate your membership and gain full access, please complete your annual membership payment:

+ +

+ Complete Payment → +

+ +
+

Once payment is processed, you'll have immediate access to:

+
    +
  • Members-only events and gatherings
  • +
  • Community directory and networking
  • +
  • Exclusive member benefits and discounts
  • +
  • LOAF newsletter and updates
  • +
+
+ +

We're excited to have you join the LOAF community!

+ +

+ Questions? Contact us at support@loaf.org +

+
+
+ + + """ + return await send_email(to_email, subject, html_content) diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..5466d0c --- /dev/null +++ b/init_db.py @@ -0,0 +1,16 @@ +""" +Database initialization script. +Creates all tables defined in models.py +""" + +from database import Base, engine +from models import User, Event, EventRSVP, SubscriptionPlan, Subscription + +def init_database(): + """Create all database tables""" + print("Creating database tables...") + Base.metadata.create_all(bind=engine) + print("✅ Database tables created successfully!") + +if __name__ == "__main__": + init_database() diff --git a/migrate_add_manual_payment.py b/migrate_add_manual_payment.py new file mode 100644 index 0000000..d52af25 --- /dev/null +++ b/migrate_add_manual_payment.py @@ -0,0 +1,40 @@ +""" +Migration script to add manual payment columns to subscriptions table. +Run this once to update the database schema. +""" + +from database import engine +from sqlalchemy import text + +def add_manual_payment_columns(): + """Add manual payment columns to subscriptions table""" + + migrations = [ + "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS manual_payment BOOLEAN NOT NULL DEFAULT FALSE", + "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS manual_payment_notes TEXT", + "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS manual_payment_admin_id UUID REFERENCES users(id)", + "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS manual_payment_date TIMESTAMP", + "ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS payment_method VARCHAR" + ] + + try: + print("Adding manual payment columns to subscriptions table...") + with engine.connect() as conn: + for sql in migrations: + print(f" Executing: {sql[:60]}...") + conn.execute(text(sql)) + conn.commit() + print("✅ Migration completed successfully!") + print("\nAdded columns:") + print(" - manual_payment (BOOLEAN)") + print(" - manual_payment_notes (TEXT)") + print(" - manual_payment_admin_id (UUID)") + print(" - manual_payment_date (TIMESTAMP)") + print(" - payment_method (VARCHAR)") + + except Exception as e: + print(f"❌ Migration failed: {e}") + raise + +if __name__ == "__main__": + add_manual_payment_columns() diff --git a/migrate_status.py b/migrate_status.py new file mode 100644 index 0000000..d90ba96 --- /dev/null +++ b/migrate_status.py @@ -0,0 +1,31 @@ +""" +Migrate user status from awaiting_event to pending_approval +Run this once to update existing database records +""" + +from database import SessionLocal +from models import User + +def migrate_status(): + db = SessionLocal() + try: + # Find all users with old status + users = db.query(User).filter(User.status == "awaiting_event").all() + + count = 0 + for user in users: + user.status = "pending_approval" + count += 1 + + db.commit() + print(f"✅ Successfully migrated {count} users from 'awaiting_event' to 'pending_approval'") + + except Exception as e: + print(f"❌ Error during migration: {e}") + db.rollback() + finally: + db.close() + +if __name__ == "__main__": + print("Starting status migration...") + migrate_status() diff --git a/models.py b/models.py new file mode 100644 index 0000000..7837fac --- /dev/null +++ b/models.py @@ -0,0 +1,141 @@ +from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, ForeignKey, JSON +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from datetime import datetime, timezone +import uuid +import enum +from database import Base + +class UserStatus(enum.Enum): + pending_email = "pending_email" + pending_approval = "pending_approval" + pre_approved = "pre_approved" + payment_pending = "payment_pending" + active = "active" + inactive = "inactive" + +class UserRole(enum.Enum): + guest = "guest" + member = "member" + admin = "admin" + +class RSVPStatus(enum.Enum): + yes = "yes" + no = "no" + maybe = "maybe" + +class SubscriptionStatus(enum.Enum): + active = "active" + expired = "expired" + cancelled = "cancelled" + +class User(Base): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email = Column(String, unique=True, nullable=False, index=True) + password_hash = Column(String, nullable=False) + first_name = Column(String, nullable=False) + last_name = Column(String, nullable=False) + phone = Column(String, nullable=False) + address = Column(String, nullable=False) + city = Column(String, nullable=False) + state = Column(String, nullable=False) + zipcode = Column(String, nullable=False) + date_of_birth = Column(DateTime, nullable=False) + lead_sources = Column(JSON, default=list) + partner_first_name = Column(String, nullable=True) + partner_last_name = Column(String, nullable=True) + partner_is_member = Column(Boolean, default=False) + partner_plan_to_become_member = Column(Boolean, default=False) + referred_by_member_name = Column(String, nullable=True) + status = Column(SQLEnum(UserStatus), default=UserStatus.pending_email, nullable=False) + role = Column(SQLEnum(UserRole), default=UserRole.guest, nullable=False) + email_verified = Column(Boolean, default=False) + email_verification_token = Column(String, nullable=True) + newsletter_subscribed = Column(Boolean, default=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + events_created = relationship("Event", back_populates="creator") + rsvps = relationship("EventRSVP", back_populates="user") + subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id") + +class Event(Base): + __tablename__ = "events" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String, nullable=False) + description = Column(Text, nullable=True) + start_at = Column(DateTime, nullable=False) + end_at = Column(DateTime, nullable=False) + location = Column(String, nullable=False) + capacity = Column(Integer, nullable=True) + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + published = Column(Boolean, default=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + creator = relationship("User", back_populates="events_created") + rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan") + +class EventRSVP(Base): + __tablename__ = "event_rsvps" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + event_id = Column(UUID(as_uuid=True), ForeignKey("events.id"), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + rsvp_status = Column(SQLEnum(RSVPStatus), default=RSVPStatus.maybe, nullable=False) + attended = Column(Boolean, default=False) + attended_at = Column(DateTime, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + event = relationship("Event", back_populates="rsvps") + user = relationship("User", back_populates="rsvps") + +class SubscriptionPlan(Base): + __tablename__ = "subscription_plans" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name = Column(String, nullable=False) + description = Column(Text, nullable=True) + price_cents = Column(Integer, nullable=False) # Price in cents + billing_cycle = Column(String, default="yearly", nullable=False) # yearly, monthly, etc. + stripe_price_id = Column(String, nullable=True) # Stripe Price ID + active = Column(Boolean, default=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + subscriptions = relationship("Subscription", back_populates="plan") + +class Subscription(Base): + __tablename__ = "subscriptions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + plan_id = Column(UUID(as_uuid=True), ForeignKey("subscription_plans.id"), nullable=False) + stripe_subscription_id = Column(String, nullable=True) # Stripe Subscription ID + stripe_customer_id = Column(String, nullable=True) # Stripe Customer ID + status = Column(SQLEnum(SubscriptionStatus), default=SubscriptionStatus.active, nullable=False) + start_date = Column(DateTime, nullable=False) + end_date = Column(DateTime, nullable=True) + amount_paid_cents = Column(Integer, nullable=True) # Amount paid in cents + + # Manual payment fields + manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment + manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment + manual_payment_admin_id = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) # Admin who processed the payment + manual_payment_date = Column(DateTime, nullable=True) # Date payment was received + payment_method = Column(String, nullable=True) # Payment method: stripe, cash, bank_transfer, check, other + + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id]) + plan = relationship("SubscriptionPlan", back_populates="subscriptions") diff --git a/payment_service.py b/payment_service.py new file mode 100644 index 0000000..8886ad6 --- /dev/null +++ b/payment_service.py @@ -0,0 +1,180 @@ +""" +Payment service for Stripe integration. +Handles subscription creation, checkout sessions, and webhook processing. +""" + +import stripe +import os +from dotenv import load_dotenv +from datetime import datetime, timezone, timedelta + +# Load environment variables +load_dotenv() + +# Initialize Stripe with secret key +stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + +# Stripe webhook secret for signature verification +STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") + +def create_checkout_session( + user_id: str, + user_email: str, + plan_id: str, + stripe_price_id: str, + success_url: str, + cancel_url: str +): + """ + Create a Stripe Checkout session for subscription payment. + + Args: + user_id: User's UUID + user_email: User's email address + plan_id: SubscriptionPlan UUID + stripe_price_id: Stripe Price ID for the plan + success_url: URL to redirect after successful payment + cancel_url: URL to redirect if user cancels + + Returns: + dict: Checkout session object with session ID and URL + """ + try: + # Create Checkout Session + checkout_session = stripe.checkout.Session.create( + customer_email=user_email, + payment_method_types=["card"], + line_items=[ + { + "price": stripe_price_id, + "quantity": 1, + } + ], + mode="subscription", + success_url=success_url, + cancel_url=cancel_url, + metadata={ + "user_id": str(user_id), + "plan_id": str(plan_id), + }, + subscription_data={ + "metadata": { + "user_id": str(user_id), + "plan_id": str(plan_id), + } + } + ) + + return { + "session_id": checkout_session.id, + "url": checkout_session.url + } + + except stripe.error.StripeError as e: + raise Exception(f"Stripe error: {str(e)}") + + +def verify_webhook_signature(payload: bytes, sig_header: str) -> dict: + """ + Verify Stripe webhook signature and construct event. + + Args: + payload: Raw webhook payload bytes + sig_header: Stripe signature header + + Returns: + dict: Verified webhook event + + Raises: + ValueError: If signature verification fails + """ + try: + event = stripe.Webhook.construct_event( + payload, sig_header, STRIPE_WEBHOOK_SECRET + ) + return event + except ValueError as e: + raise ValueError(f"Invalid payload: {str(e)}") + except stripe.error.SignatureVerificationError as e: + raise ValueError(f"Invalid signature: {str(e)}") + + +def get_subscription_end_date(billing_cycle: str = "yearly") -> datetime: + """ + Calculate subscription end date based on billing cycle. + + Args: + billing_cycle: "yearly" or "monthly" + + Returns: + datetime: End date for the subscription + """ + now = datetime.now(timezone.utc) + + if billing_cycle == "yearly": + # Add 1 year + return now + timedelta(days=365) + elif billing_cycle == "monthly": + # Add 1 month (approximation) + return now + timedelta(days=30) + else: + # Default to yearly + return now + timedelta(days=365) + + +def create_stripe_price( + product_name: str, + price_cents: int, + billing_cycle: str = "yearly" +) -> str: + """ + Create a Stripe Price object for a subscription plan. + + Args: + product_name: Name of the product/plan + price_cents: Price in cents + billing_cycle: "yearly" or "monthly" + + Returns: + str: Stripe Price ID + """ + try: + # Create a product first + product = stripe.Product.create(name=product_name) + + # Determine recurring interval + interval = "year" if billing_cycle == "yearly" else "month" + + # Create price + price = stripe.Price.create( + product=product.id, + unit_amount=price_cents, + currency="usd", + recurring={"interval": interval}, + ) + + return price.id + + except stripe.error.StripeError as e: + raise Exception(f"Stripe error creating price: {str(e)}") + + +def get_customer_portal_url(stripe_customer_id: str, return_url: str) -> str: + """ + Create a Stripe Customer Portal session for subscription management. + + Args: + stripe_customer_id: Stripe Customer ID + return_url: URL to return to after portal session + + Returns: + str: Customer portal URL + """ + try: + session = stripe.billing_portal.Session.create( + customer=stripe_customer_id, + return_url=return_url, + ) + return session.url + except stripe.error.StripeError as e: + raise Exception(f"Stripe error creating portal session: {str(e)}") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..64b874f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,74 @@ +aiosmtplib==5.0.0 +annotated-types==0.7.0 +anyio==4.11.0 +bcrypt==4.1.3 +black==25.11.0 +boto3==1.41.3 +botocore==1.41.3 +certifi==2025.11.12 +cffi==2.0.0 +charset-normalizer==3.4.4 +click==8.3.1 +cryptography==46.0.3 +dnspython==2.8.0 +ecdsa==0.19.1 +email-validator==2.3.0 +fastapi==0.110.1 +flake8==7.3.0 +greenlet==3.2.4 +h11==0.16.0 +idna==3.11 +iniconfig==2.3.0 +isort==7.0.0 +jmespath==1.0.1 +jq==1.10.0 +markdown-it-py==4.0.0 +mccabe==0.7.0 +mdurl==0.1.2 +motor==3.3.1 +mypy==1.18.2 +mypy_extensions==1.1.0 +numpy==2.3.5 +oauthlib==3.3.1 +packaging==25.0 +pandas==2.3.3 +passlib==1.7.4 +pathspec==0.12.1 +platformdirs==4.5.0 +pluggy==1.6.0 +psycopg2-binary==2.9.11 +pyasn1==0.6.1 +pycodestyle==2.14.0 +pycparser==2.23 +pydantic==2.12.4 +pydantic_core==2.41.5 +pyflakes==3.4.0 +Pygments==2.19.2 +PyJWT==2.10.1 +pymongo==4.5.0 +pytest==9.0.1 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.1 +python-jose==3.5.0 +python-multipart==0.0.20 +pytokens==0.3.0 +pytz==2025.2 +requests==2.32.5 +requests-oauthlib==2.0.0 +rich==14.2.0 +rsa==4.9.1 +s3transfer==0.15.0 +s5cmd==0.2.0 +shellingham==1.5.4 +six==1.17.0 +sniffio==1.3.1 +stripe==11.2.0 +SQLAlchemy==2.0.44 +starlette==0.37.2 +typer==0.20.0 +typing-inspection==0.4.2 +typing_extensions==4.15.0 +tzdata==2025.2 +urllib3==2.5.0 +uvicorn==0.25.0 +watchfiles==1.1.1 diff --git a/seed_plans.py b/seed_plans.py new file mode 100644 index 0000000..762b6ac --- /dev/null +++ b/seed_plans.py @@ -0,0 +1,81 @@ +""" +Seed subscription plans into the database. +Creates a default annual membership plan for testing. +""" + +import os +from database import SessionLocal +from models import SubscriptionPlan +from payment_service import create_stripe_price + +def seed_plans(): + """Create default subscription plans""" + db = SessionLocal() + + try: + # Check if plans already exist + existing_plans = db.query(SubscriptionPlan).count() + if existing_plans > 0: + print(f"⚠️ Found {existing_plans} existing plan(s). Skipping seed.") + return + + print("Creating subscription plans...") + + # Option 1: Create plan WITHOUT Stripe Price ID (for testing without Stripe) + # Uncomment this if you want to test UI without actual Stripe integration + annual_plan = SubscriptionPlan( + name="Annual Membership", + description="Full access to all LOAF community benefits for one year", + price_cents=10000, # $100.00 + billing_cycle="yearly", + stripe_price_id=None, # Set to None for local testing + active=True + ) + + # Option 2: Create plan WITH Stripe Price ID (requires valid Stripe API key) + # Uncomment this block and comment out Option 1 if you have Stripe configured + """ + try: + stripe_price_id = create_stripe_price( + product_name="LOAF Annual Membership", + price_cents=10000, # $100.00 + billing_cycle="yearly" + ) + print(f"✅ Created Stripe Price: {stripe_price_id}") + + annual_plan = SubscriptionPlan( + name="Annual Membership", + description="Full access to all LOAF community benefits for one year", + price_cents=10000, + billing_cycle="yearly", + stripe_price_id=stripe_price_id, + active=True + ) + except Exception as e: + print(f"❌ Failed to create Stripe Price: {e}") + print("Creating plan without Stripe Price ID for local testing...") + annual_plan = SubscriptionPlan( + name="Annual Membership", + description="Full access to all LOAF community benefits for one year", + price_cents=10000, + billing_cycle="yearly", + stripe_price_id=None, + active=True + ) + """ + + db.add(annual_plan) + db.commit() + db.refresh(annual_plan) + + print(f"✅ Created plan: {annual_plan.name} (${annual_plan.price_cents/100:.2f}/{annual_plan.billing_cycle})") + print(f" Plan ID: {annual_plan.id}") + + except Exception as e: + print(f"❌ Error seeding plans: {e}") + db.rollback() + finally: + db.close() + +if __name__ == "__main__": + seed_plans() diff --git a/server.py b/server.py new file mode 100644 index 0000000..398c957 --- /dev/null +++ b/server.py @@ -0,0 +1,1191 @@ +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=["*"], +) \ No newline at end of file diff --git a/test_plans_endpoint.py b/test_plans_endpoint.py new file mode 100644 index 0000000..66d482f --- /dev/null +++ b/test_plans_endpoint.py @@ -0,0 +1,65 @@ +""" +Test script to diagnose the subscription plans endpoint issue. +""" + +from database import SessionLocal +from models import SubscriptionPlan, Subscription, SubscriptionStatus + +def test_plans_query(): + """Test the same query that the endpoint uses""" + db = SessionLocal() + + try: + print("Testing subscription plans query...\n") + + # Step 1: Get all plans + plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.created_at.desc()).all() + print(f"✅ Found {len(plans)} plan(s)") + + # Step 2: For each plan, get subscriber count (same as endpoint) + result = [] + for plan in plans: + print(f"\nProcessing plan: {plan.name}") + + try: + subscriber_count = db.query(Subscription).filter( + Subscription.plan_id == plan.id, + Subscription.status == SubscriptionStatus.active + ).count() + print(f" ✓ Subscriber count: {subscriber_count}") + except Exception as e: + print(f" ❌ Error counting subscribers: {e}") + raise + + 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 + }) + + print("\n" + "="*50) + print("✅ Query completed successfully!") + print("="*50) + for plan in result: + print(f"\n{plan['name']}") + print(f" Price: ${plan['price_cents']/100:.2f}") + print(f" Cycle: {plan['billing_cycle']}") + print(f" Active: {plan['active']}") + print(f" Subscribers: {plan['subscriber_count']}") + + except Exception as e: + print(f"\n❌ ERROR: {e}") + import traceback + traceback.print_exc() + finally: + db.close() + +if __name__ == "__main__": + test_plans_query()