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"""
+
+
+
+
+
+
+
+
+
+
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"""
+
+
+
+
+
+
+
+
+
+
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"""
+
+
+
+
+
+
+
+
+
+
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()