From 6ef7685ade5f3d5f81da4931a00e5b32a6dcd6ea Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:43:37 +0700 Subject: [PATCH] first commit --- .env | 14 + README.md | 0 __pycache__/auth.cpython-311.pyc | Bin 0 -> 4627 bytes __pycache__/auth.cpython-312.pyc | Bin 0 -> 4222 bytes __pycache__/database.cpython-311.pyc | Bin 0 -> 1282 bytes __pycache__/database.cpython-312.pyc | Bin 0 -> 1158 bytes __pycache__/email_service.cpython-311.pyc | Bin 0 -> 6949 bytes __pycache__/email_service.cpython-312.pyc | Bin 0 -> 9769 bytes __pycache__/models.cpython-311.pyc | Bin 0 -> 7549 bytes __pycache__/models.cpython-312.pyc | Bin 0 -> 10084 bytes __pycache__/payment_service.cpython-312.pyc | Bin 0 -> 6118 bytes __pycache__/server.cpython-311.pyc | Bin 0 -> 32878 bytes __pycache__/server.cpython-312.pyc | Bin 0 -> 55940 bytes auth.py | 80 ++ create_admin.py | 73 ++ database.py | 23 + email_service.py | 182 +++ init_db.py | 16 + migrate_add_manual_payment.py | 40 + migrate_status.py | 31 + models.py | 141 +++ payment_service.py | 180 +++ requirements.txt | 74 ++ seed_plans.py | 81 ++ server.py | 1191 +++++++++++++++++++ test_plans_endpoint.py | 65 + 26 files changed, 2191 insertions(+) create mode 100644 .env create mode 100644 README.md create mode 100644 __pycache__/auth.cpython-311.pyc create mode 100644 __pycache__/auth.cpython-312.pyc create mode 100644 __pycache__/database.cpython-311.pyc create mode 100644 __pycache__/database.cpython-312.pyc create mode 100644 __pycache__/email_service.cpython-311.pyc create mode 100644 __pycache__/email_service.cpython-312.pyc create mode 100644 __pycache__/models.cpython-311.pyc create mode 100644 __pycache__/models.cpython-312.pyc create mode 100644 __pycache__/payment_service.cpython-312.pyc create mode 100644 __pycache__/server.cpython-311.pyc create mode 100644 __pycache__/server.cpython-312.pyc create mode 100644 auth.py create mode 100644 create_admin.py create mode 100644 database.py create mode 100644 email_service.py create mode 100644 init_db.py create mode 100644 migrate_add_manual_payment.py create mode 100644 migrate_status.py create mode 100644 models.py create mode 100644 payment_service.py create mode 100644 requirements.txt create mode 100644 seed_plans.py create mode 100644 server.py create mode 100644 test_plans_endpoint.py 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 0000000000000000000000000000000000000000..0d252acaaed60a82cadc89b92a57bc56aff80619 GIT binary patch literal 4627 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 VOLdswk#2qpGw*NFUWb|P^&k2B8H)e_ literal 0 HcmV?d00001 diff --git a/__pycache__/auth.cpython-312.pyc b/__pycache__/auth.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bc7dcd370869173774742246d47dbb5c3780fcb1 GIT binary patch literal 4222 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 ObXb~v=SMm^D*6}5l&-G; literal 0 HcmV?d00001 diff --git a/__pycache__/database.cpython-311.pyc b/__pycache__/database.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..238df63e6a68a070c55bd489bd0469d818391aad GIT binary patch literal 1282 zcma)5&1)N15TE`2kmQx)e73~6)DQ$gDosMb1QVMyv^XTiP72w}cBS`XZL%NA+m)Rd zC+N_FYz#5Iq(`4zit`8b*gru69hQZHp_k?qgD)nh%#-XyBA2{3GjHbiX5KO%`)8qG z0$=&>ykA9t|N6$UP>#<-T>y9u1c=~6AlQPybR#3Uvif0NslFar5F;uPLVyJ+m4KwwXh{BK~NCh`)L3U4`H z60$;#Q8%Fu4VS%;B2Xl>=DW;IycOzH-I!zfn8vXeg@OBovbzZ6{KzE^i4q#Fpeg_C zCiOMBj8>MvHFtk*es1Xp=fUEgwd;*2PL>&spZYVEN;9Txrs2l%YQ)G*KdQNY9q(tZ zUA^*sC7?l-vbgRw98$eqk}}h6CQ&U40x!u7u0QgdaXpi(UPx*m(?Uf?{ucRg6U71u z*yiM8W3UB?ek#KYFkl^if!8##HAhi=DfG=evO2|w_q-mCCTdI+mRNw`}Qv;5Ma zc6WHTnNstken~}Ajrq5y-k-0yjYg&F)}GLiREV3nyz1q~^Gsc)3D$y*qu(jy_$G?~ zakt=KNI%H_yA>JeFDn#SyCPv2*yjDjSV?OEMWW>%fu+o)WJkBl>=G>hr;)& zOyidBIFCG^I!>lGTt>qr(--gEyYJlkX))7qZafx+nS=>5ox7`pd1M7{$n#i`eiiEt})#+rdw)iecfK)Y5uMJbGu_LbghM+wSZ3AxsG7%Q6n?XRUhmqmowQK?f>A3WAL>Ow)hI$0i9!W(m7+8iVlUhE&NSX+f6UA} zZmdvQh(kjKi8xS>NSsjMh{TaQ7phc(E?8>C1#XqXp+Y_I);~&a9cgC1_syr7ecyZg zOR;DGj?Jmp{A&`xUs*62>KGjUi{K7Wpd^5h*o0(Q3ZziBB~)#7h?PLIH9S{? zT&Uankkf*EXxIh;37EmmeKNXv{nX^Cqunl|MrTEu>zM!(B>|?bzj&mYp!Jt0=xC=p z&F^C_*|5ua8)4tln)hr?~6B(R0oJx$Yv8}f(HB|perQX|VXDJ@(XxpXPZD^W`9e7jUsmSGx6|k3ALThxJDD0-H8=2DEZpLmU+MEl zM4jD8>c?7zXGKV-jtOEO^6W=TrC6les8_g_5xF+DOW-$oE{k->x$XzdaZ;`Aa`YqB zS1w;(b(Y>+NpsjyK98eR#w59($+c0Z@?sV!i)g|y^Y7pg3E<_7*53+a+6mYN{tAYf z`69&SkxU2~z?lJ@AHb`BKz;zT132~2EbZyHFZ^a!@0!)Vejm(R@9u5hExdFOW*?L) zyS<&>?#-Q>Uq`>z*A7ape&J#MnZ50U{F%OXzx2X4ouAa7FWxIPkk37PkbADL94acA I;Z=O`fA-=d9{>OV literal 0 HcmV?d00001 diff --git a/__pycache__/email_service.cpython-311.pyc b/__pycache__/email_service.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0892a63a14375e8fb92348e696fe6542db92a23 GIT binary patch literal 6949 zcmeGgZA=^I`TU6uhmFA`ls3%^NlRFRZ3s|m3>yJE$r3&kXrr(XbodVY!uig6?~K8L z7HQQ)qew(mr65g_@@E}YI;lT9Y168z`#oFo59=hPN>ionpQo-;)n9ww_xy!{m93K| zO||dd@ArA1@8`MqH_gqB4EeDEQEg|KztB#pu$SsD&zKqJF~c*wNn#dBZI(<4^P<^A z+7`)@ur69@+a%c*ZFHS|(Qal0`@M!TK*908iD90>Yy1|S0&~we8wQ>>mB(vq-pbqF zhqIr-Yy1{jfZBYDop!$A@Xor^U8jKI;GKtIEV@sztMSOLmQ(C%;@P^hJg3;z%)9D# zwVq;^n{TPx)y8`SFW(Apo8Vos^WL|ui|ur*9r{-O4D`>?{#n}Zfc{zDG|%)t_ctP# znO>9bn7%O`UJ%wbXg8DgbV}01B!@NKIX}HHw{(4Wej(#h6I#+2OK_s(iz*ayDiTU|Ev$v;oH=U2%o}FF_Pfy&ql{q6TSV&46KS~IR zWdW;kF-ZZtstXCEOxtv29?eOdwxZyK&H|vBh49SO((AWxW!mGKmJIs+k`m>lxT0#o zD}g{jcWBCzKoHg&)YS4lA*$(YTuVqxQAO4SS&Ldq%1J6GyuX0=%MXE?n~VwU#>r?E zt!Kc74r}6;MXP8x!~6ZrD>E#9#S*gr8Jf%@$`otviraA)JD z`M8~Y(>$ZKRdK8OnN5p!raG>5)U<1;uce)D`jCAeJmIslHw5wW&G#*DFpn+21h01r z&bW@}48igOqjgqsxX9p21NpCNK#)n8+Oy6V~mVrg70YCKiQ>A?tQYiNyr0TSa+A(Hl8YA?`~Omvt)v zi)~~jQ4HM~UXKb%O;lvvCSa^!-J)u^*N&UX5+X(HChHm!fF~CdbSHHo)IFL6mB2vs z_suy!mrVMXx#%jK;HRQk0#2-nQNfqo&>P9YhCjlWV3!A8br&4WU*W&a6s_lM?FX&r zpEl=Oefd`3PQ$^uSF)WG@ci~IqlR*);gS6vccBA8!}?Bh;oL=NICGB9FT5}8{d9jg z=N-s<2X>qVhkMuc!1ZV&=jh5iy0VV0uk1|AxgukAcKmvpRtIeB?#%}`vmO0U*P(zX z#}4G#fh;>vnHt)^mhBjVC&v!w+2JfZT$u{)-^z9b;mNV1d3H3*juzU^J{p8$+|NB! z3T)d!`-=yj&V%-DcnW7ai_H!0#=kRA{DTxlros6w#aAti)4kjDpegGOeRK&5cyf-h zykjiu7%RBjcC@b#n!H8EY-_I;2M*UR`+&{1PkrQo0-irieq#Dvr(;sSXqEp1P1TUMre-G>?#_h~QT1_SthOm|yfI2wtJ;PnRQqIXS>ZR(eY65@qHl#u zh|)$7^-N8pc}`Y)E~5!7a?)j_Laz@jL|hpqC<}dYL5#(XTNDoho%|qGkTniAVK^tjt@ouxUW*6Oa3GLeAB7Pd6XhTZATFgTqbSMoJUGQ5 z8lcm4u;M@v1w@QDhLx1e2PsAz?u%iL7r|9s>c29?3$e@SLS$faawLKRFF`vTcy(eZ zg8Bo2mwIbXxk69TAgAi%BryhvL;-|=M}adKj_M;oQz}pj3ymUx0!L^p;T7)(8cXa* z$3;!3p>MQ~$q~$@WN@j#Xf5SF0I`VX=X8Fs^{H)bnI2F#OtJxR%V2zed?Zej=Zec@T}S+CYdG*HBb~ z=r`6~QPl40Jkj9VHHM(;7SKiU{_#741PQl5LgFkK?WB@Oq-0Us=mG@#>tOof;JTqY zR6q}pJ10555a;Aow4tOxqcFlkOjJQ*NhXmNwKyadK6FRu!2%N2qo9K{M8~-`fo{2{ zh%zEB1u!+R^M(A$!M_1j1$s^~nv$!+7vF zz&xN%RH-utLjb}kMax)8gJpgjEhKB|TgC#n8bq`LM{X;nr_HnH5pLkN}!qfz>LWk49$^xZAKc_3j=x!Rg6-MTS{T6nw_a6 z_!7Y)+(VY0rMcFj^%Oze2eSk4su&6*@%p0kcs>prb=K-FxO%s*70q2GS46C8 zOC&`;K7;rnmx#hR?QpIaX|({2Tus91Ia7~ugGWY@LdhT zG~=T=L(SLtL=rOUQp$0iQ_)ocw!@Zb2*gK+ zlH=e?AdO2Snm~}Re-d0E#FO|Bfesl5pcObb1)-nDRACKrmlLDbcS!<1J(2|6A|8(* zLw(+a5%~VCY~YT7Bt=;z$yA!dr3(##q*IUvAYdm=ZzxG1t-4gHCQed@Rio&MVH3Xs zL8BZm{UppdsoVb7fwGeZN<&nWDH_t=rkb``{1t*}?eW%(CdyaU z#=Q{vbk`fm-48HYcg6$_1WE3J97{CZE-m46bcrCZi5RYYEU<)L2I&?^(ddm(^%lvv zVWM|UG$XkHTWMNC(+J9+4;YFs%{snugc1s$f^T6w1}n+cv6_OiXfc^g1;)8e{{`k^ z_V80+eA&ZKf$?O^Pl0i5)Boqr&YiP)XXoBX&UrEKytr-uoOM0?@$SrnnZ42MrI8%_ zTAqDv+gV`UJCl#h?=?Plz3bY$zdxC43FKP>IkrE~_QP~bYia7OkIcE2EBTfy+s(%h zwtCFwJa4qM#p7&heb`{*7hnd|s zAH2CIXMN$EJCb)tw%MZfys3RpEizE;&(rD?3$5yltEQJtWBZdu2C9!?2~_pPoT(d@ P05GTs6sS%ts9639><9UO literal 0 HcmV?d00001 diff --git a/__pycache__/email_service.cpython-312.pyc b/__pycache__/email_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6296597a0186b395f64a1ee5ec6e2d19435a4ddc GIT binary patch literal 9769 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 literal 0 HcmV?d00001 diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5bbab35b574d87743af54b22732f51ca7fdd7f61 GIT binary patch literal 10084 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 I_iTFhzb716WdHyG literal 0 HcmV?d00001 diff --git a/__pycache__/payment_service.cpython-312.pyc b/__pycache__/payment_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..facceebaf70acd28301c25f235a199d3f891382a GIT binary patch literal 6118 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 Zdp6A@w2snkq;wl8UA-lQ1v8=U{{k_}T~`1A literal 0 HcmV?d00001 diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e2abfd3c5bdc422e0b929d95d4a2a833885bffa GIT binary patch literal 55940 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 N1xr3-s@N&}{};Q~=nwz^ literal 0 HcmV?d00001 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()