Update:- Membership Plan- Donation- Member detail for Member Directory

This commit is contained in:
Koncept Kit
2025-12-11 19:28:48 +07:00
parent f051976881
commit e875700b8e
7 changed files with 890 additions and 69 deletions

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,314 @@
"""
Database Migration: Billing Enhancements
=========================================
This migration adds support for:
1. Custom billing cycles (recurring date ranges like Jan 1 - Dec 31)
2. Dynamic pricing with minimum/suggested amounts
3. Donation tracking separate from base subscription fees
Changes:
- SubscriptionPlan: Add 8 new fields for custom cycles and dynamic pricing
- Subscription: Add 2 new fields for donation tracking
Backward compatibility:
- Keeps existing fields (price_cents, stripe_price_id) for safe rollback
- Backfills new fields from existing data
Usage:
python migrate_billing_enhancements.py
"""
import os
import sys
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from datetime import datetime
# Import database connection
from database import engine, SessionLocal
def run_migration():
"""Execute the migration to add billing enhancement fields."""
print("=" * 60)
print("LOAF Membership - Billing Enhancements Migration")
print("=" * 60)
print()
# Create session
db = SessionLocal()
try:
# =====================================================================
# STEP 1: Add new columns to subscription_plans table
# =====================================================================
print("STEP 1: Adding new columns to subscription_plans table...")
subscription_plan_migrations = [
# Custom billing cycle fields
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS custom_cycle_enabled BOOLEAN DEFAULT FALSE",
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS custom_cycle_start_month INTEGER",
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS custom_cycle_start_day INTEGER",
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS custom_cycle_end_month INTEGER",
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS custom_cycle_end_day INTEGER",
# Dynamic pricing fields
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS minimum_price_cents INTEGER DEFAULT 3000 NOT NULL",
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS suggested_price_cents INTEGER",
"ALTER TABLE subscription_plans ADD COLUMN IF NOT EXISTS allow_donation BOOLEAN DEFAULT TRUE",
]
for sql in subscription_plan_migrations:
try:
db.execute(text(sql))
print(f"{sql.split('ADD COLUMN IF NOT EXISTS')[1].split()[0]}")
except Exception as e:
print(f"{sql.split('ADD COLUMN IF NOT EXISTS')[1].split()[0]} - {str(e)}")
db.commit()
print(" ✓ Subscription plans table columns added successfully")
print()
# =====================================================================
# STEP 2: Backfill subscription_plans data
# =====================================================================
print("STEP 2: Backfilling subscription_plans data...")
backfill_plans_sql = """
UPDATE subscription_plans
SET
minimum_price_cents = COALESCE(minimum_price_cents, price_cents),
suggested_price_cents = COALESCE(suggested_price_cents, price_cents),
custom_cycle_enabled = COALESCE(custom_cycle_enabled, FALSE),
allow_donation = COALESCE(allow_donation, TRUE)
WHERE minimum_price_cents IS NULL OR suggested_price_cents IS NULL
"""
result = db.execute(text(backfill_plans_sql))
db.commit()
print(f" ✓ Backfilled {result.rowcount} subscription plan records")
print()
# =====================================================================
# STEP 3: Add new columns to subscriptions table
# =====================================================================
print("STEP 3: Adding new columns to subscriptions table...")
subscription_migrations = [
"ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS base_subscription_cents INTEGER",
"ALTER TABLE subscriptions ADD COLUMN IF NOT EXISTS donation_cents INTEGER DEFAULT 0 NOT NULL",
]
for sql in subscription_migrations:
try:
db.execute(text(sql))
print(f"{sql.split('ADD COLUMN IF NOT EXISTS')[1].split()[0]}")
except Exception as e:
print(f"{sql.split('ADD COLUMN IF NOT EXISTS')[1].split()[0]} - {str(e)}")
db.commit()
print(" ✓ Subscriptions table columns added successfully")
print()
# =====================================================================
# STEP 4: Backfill subscriptions data
# =====================================================================
print("STEP 4: Backfilling subscriptions data...")
backfill_subscriptions_sql = """
UPDATE subscriptions
SET
base_subscription_cents = COALESCE(base_subscription_cents, amount_paid_cents, 0),
donation_cents = COALESCE(donation_cents, 0)
WHERE base_subscription_cents IS NULL
"""
result = db.execute(text(backfill_subscriptions_sql))
db.commit()
print(f" ✓ Backfilled {result.rowcount} subscription records")
print()
# =====================================================================
# STEP 5: Verify migration
# =====================================================================
print("STEP 5: Verifying migration...")
# Check subscription_plans columns
check_plans_sql = """
SELECT
COUNT(*) as total,
COUNT(minimum_price_cents) as has_minimum,
COUNT(suggested_price_cents) as has_suggested,
COUNT(custom_cycle_enabled) as has_custom_enabled
FROM subscription_plans
"""
result = db.execute(text(check_plans_sql)).fetchone()
print(f" Subscription Plans: {result[0]} total")
print(f" - {result[1]} have minimum_price_cents")
print(f" - {result[2]} have suggested_price_cents")
print(f" - {result[3]} have custom_cycle_enabled")
# Check subscriptions columns
check_subscriptions_sql = """
SELECT
COUNT(*) as total,
COUNT(base_subscription_cents) as has_base,
COUNT(donation_cents) as has_donation,
SUM(base_subscription_cents) as total_base,
SUM(donation_cents) as total_donations
FROM subscriptions
"""
result = db.execute(text(check_subscriptions_sql)).fetchone()
print(f" Subscriptions: {result[0]} total")
print(f" - {result[1]} have base_subscription_cents")
print(f" - {result[2]} have donation_cents")
print(f" - Total base: ${(result[3] or 0) / 100:.2f}")
print(f" - Total donations: ${(result[4] or 0) / 100:.2f}")
print()
# =====================================================================
# STEP 6: Add constraints (optional validation)
# =====================================================================
print("STEP 6: Adding data validation constraints...")
# Add check constraint for custom cycle months (1-12)
try:
db.execute(text("""
ALTER TABLE subscription_plans
ADD CONSTRAINT IF NOT EXISTS check_custom_cycle_months
CHECK (
(custom_cycle_start_month IS NULL OR (custom_cycle_start_month >= 1 AND custom_cycle_start_month <= 12)) AND
(custom_cycle_end_month IS NULL OR (custom_cycle_end_month >= 1 AND custom_cycle_end_month <= 12))
)
"""))
print(" ✓ Added month validation constraint (1-12)")
except Exception as e:
print(f" ⚠ Month constraint: {str(e)}")
# Add check constraint for custom cycle days (1-31)
try:
db.execute(text("""
ALTER TABLE subscription_plans
ADD CONSTRAINT IF NOT EXISTS check_custom_cycle_days
CHECK (
(custom_cycle_start_day IS NULL OR (custom_cycle_start_day >= 1 AND custom_cycle_start_day <= 31)) AND
(custom_cycle_end_day IS NULL OR (custom_cycle_end_day >= 1 AND custom_cycle_end_day <= 31))
)
"""))
print(" ✓ Added day validation constraint (1-31)")
except Exception as e:
print(f" ⚠ Day constraint: {str(e)}")
# Add check constraint for minimum price (>= $30)
try:
db.execute(text("""
ALTER TABLE subscription_plans
ADD CONSTRAINT IF NOT EXISTS check_minimum_price
CHECK (minimum_price_cents >= 3000)
"""))
print(" ✓ Added minimum price constraint ($30+)")
except Exception as e:
print(f" ⚠ Minimum price constraint: {str(e)}")
db.commit()
print()
# =====================================================================
# MIGRATION COMPLETE
# =====================================================================
print("=" * 60)
print("✓ MIGRATION COMPLETED SUCCESSFULLY")
print("=" * 60)
print()
print("Summary:")
print(" - Added 8 new columns to subscription_plans table")
print(" - Added 2 new columns to subscriptions table")
print(" - Backfilled all existing data")
print(" - Added validation constraints")
print()
print("Next Steps:")
print(" 1. Update models.py with new column definitions")
print(" 2. Update server.py endpoints for custom billing")
print(" 3. Update frontend components for dynamic pricing")
print()
print("Rollback Notes:")
print(" - Old fields (price_cents, stripe_price_id) are preserved")
print(" - To rollback, revert code and ignore new columns")
print(" - Database can coexist with old and new fields")
print()
except Exception as e:
db.rollback()
print()
print("=" * 60)
print("✗ MIGRATION FAILED")
print("=" * 60)
print(f"Error: {str(e)}")
print()
print("No changes have been committed to the database.")
sys.exit(1)
finally:
db.close()
def rollback_migration():
"""Rollback the migration by removing new columns (use with caution)."""
print("=" * 60)
print("ROLLBACK: Billing Enhancements Migration")
print("=" * 60)
print()
print("WARNING: This will remove all custom billing cycle and donation data!")
confirm = input("Type 'ROLLBACK' to confirm: ")
if confirm != "ROLLBACK":
print("Rollback cancelled.")
return
db = SessionLocal()
try:
print("Removing columns from subscription_plans...")
db.execute(text("ALTER TABLE subscription_plans DROP COLUMN IF EXISTS custom_cycle_enabled"))
db.execute(text("ALTER TABLE subscription_plans DROP COLUMN IF EXISTS custom_cycle_start_month"))
db.execute(text("ALTER TABLE subscription_plans DROP COLUMN IF EXISTS custom_cycle_start_day"))
db.execute(text("ALTER TABLE subscription_plans DROP COLUMN IF EXISTS custom_cycle_end_month"))
db.execute(text("ALTER TABLE subscription_plans DROP COLUMN IF EXISTS custom_cycle_end_day"))
db.execute(text("ALTER TABLE subscription_plans DROP COLUMN IF EXISTS minimum_price_cents"))
db.execute(text("ALTER TABLE subscription_plans DROP COLUMN IF EXISTS suggested_price_cents"))
db.execute(text("ALTER TABLE subscription_plans DROP COLUMN IF EXISTS allow_donation"))
print("Removing columns from subscriptions...")
db.execute(text("ALTER TABLE subscriptions DROP COLUMN IF EXISTS base_subscription_cents"))
db.execute(text("ALTER TABLE subscriptions DROP COLUMN IF EXISTS donation_cents"))
db.commit()
print("✓ Rollback completed successfully")
except Exception as e:
db.rollback()
print(f"✗ Rollback failed: {str(e)}")
sys.exit(1)
finally:
db.close()
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(description="Billing Enhancements Migration")
parser.add_argument(
"--rollback",
action="store_true",
help="Rollback the migration (removes new columns)"
)
args = parser.parse_args()
if args.rollback:
rollback_migration()
else:
run_migration()

View File

@@ -143,10 +143,23 @@ class SubscriptionPlan(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String, nullable=False) name = Column(String, nullable=False)
description = Column(Text, nullable=True) description = Column(Text, nullable=True)
price_cents = Column(Integer, nullable=False) # Price in cents price_cents = Column(Integer, nullable=False) # Price in cents (legacy, kept for backward compatibility)
billing_cycle = Column(String, default="yearly", nullable=False) # yearly, monthly, etc. billing_cycle = Column(String, default="yearly", nullable=False) # yearly, monthly, quarterly, lifetime, custom
stripe_price_id = Column(String, nullable=True) # Stripe Price ID stripe_price_id = Column(String, nullable=True) # Stripe Price ID (legacy, deprecated)
active = Column(Boolean, default=True) active = Column(Boolean, default=True)
# Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31)
custom_cycle_enabled = Column(Boolean, default=False, nullable=False)
custom_cycle_start_month = Column(Integer, nullable=True) # 1-12
custom_cycle_start_day = Column(Integer, nullable=True) # 1-31
custom_cycle_end_month = Column(Integer, nullable=True) # 1-12
custom_cycle_end_day = Column(Integer, nullable=True) # 1-31
# Dynamic pricing fields
minimum_price_cents = Column(Integer, default=3000, nullable=False) # $30 minimum
suggested_price_cents = Column(Integer, nullable=True) # Suggested price (can be higher than minimum)
allow_donation = Column(Boolean, default=True, nullable=False) # Allow members to add donations
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) 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)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -164,7 +177,12 @@ class Subscription(Base):
status = Column(SQLEnum(SubscriptionStatus), default=SubscriptionStatus.active, nullable=False) status = Column(SQLEnum(SubscriptionStatus), default=SubscriptionStatus.active, nullable=False)
start_date = Column(DateTime, nullable=False) start_date = Column(DateTime, nullable=False)
end_date = Column(DateTime, nullable=True) end_date = Column(DateTime, nullable=True)
amount_paid_cents = Column(Integer, nullable=True) # Amount paid in cents amount_paid_cents = Column(Integer, nullable=True) # Total amount paid in cents (base + donation)
# Donation tracking fields (for transparency and tax reporting)
base_subscription_cents = Column(Integer, nullable=False) # Plan base price (minimum)
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
# Note: amount_paid_cents = base_subscription_cents + donation_cents
# Manual payment fields # Manual payment fields
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment

View File

@@ -122,6 +122,131 @@ def get_subscription_end_date(billing_cycle: str = "yearly") -> datetime:
return now + timedelta(days=365) return now + timedelta(days=365)
def calculate_subscription_period(plan, start_date=None, admin_override_dates=None):
"""
Calculate subscription start and end dates based on plan's custom cycle or billing_cycle.
Supports three scenarios:
1. Plan with custom billing cycle (e.g., Jan 1 - Dec 31 recurring annually)
2. Admin-overridden custom dates for manual activation
3. Standard relative billing cycle (30/90/365 days from start_date)
Args:
plan: SubscriptionPlan object with custom_cycle fields
start_date: Optional custom start date (defaults to now)
admin_override_dates: Optional dict with {'start_date': datetime, 'end_date': datetime}
Returns:
tuple: (start_date, end_date) as datetime objects
Examples:
# Plan with Jan 1 - Dec 31 custom cycle, subscribing on May 15, 2025
>>> calculate_subscription_period(plan)
(datetime(2025, 5, 15), datetime(2025, 12, 31))
# Plan with Jul 1 - Jun 30 fiscal year cycle, subscribing on Aug 20, 2025
>>> calculate_subscription_period(plan)
(datetime(2025, 8, 20), datetime(2026, 6, 30))
# Admin override for custom dates
>>> calculate_subscription_period(plan, admin_override_dates={'start_date': ..., 'end_date': ...})
(custom_start, custom_end)
"""
# Admin override takes precedence
if admin_override_dates:
return (admin_override_dates['start_date'], admin_override_dates['end_date'])
# Default start date to now if not provided
if start_date is None:
start_date = datetime.now(timezone.utc)
# Check if plan uses custom billing cycle
if plan.custom_cycle_enabled and plan.custom_cycle_start_month and plan.custom_cycle_start_day:
# Calculate end date based on recurring date range
current_year = start_date.year
# Create end date for current cycle
try:
# Check if this is a year-spanning cycle (e.g., Jul 1 - Jun 30)
year_spanning = plan.custom_cycle_end_month < plan.custom_cycle_start_month
if year_spanning:
# Fiscal year scenario: determine if we're in current or next fiscal year
cycle_start_this_year = datetime(current_year, plan.custom_cycle_start_month,
plan.custom_cycle_start_day, tzinfo=timezone.utc)
if start_date >= cycle_start_this_year:
# We're after the start of the current fiscal year
end_date = datetime(current_year + 1, plan.custom_cycle_end_month,
plan.custom_cycle_end_day, 23, 59, 59, tzinfo=timezone.utc)
else:
# We're before the start, so we're in the previous fiscal year
end_date = datetime(current_year, plan.custom_cycle_end_month,
plan.custom_cycle_end_day, 23, 59, 59, tzinfo=timezone.utc)
else:
# Calendar-aligned cycle (e.g., Jan 1 - Dec 31)
end_date = datetime(current_year, plan.custom_cycle_end_month,
plan.custom_cycle_end_day, 23, 59, 59, tzinfo=timezone.utc)
# If end date has already passed this year, use next year's end date
if end_date < start_date:
end_date = datetime(current_year + 1, plan.custom_cycle_end_month,
plan.custom_cycle_end_day, 23, 59, 59, tzinfo=timezone.utc)
return (start_date, end_date)
except ValueError:
# Invalid date (e.g., Feb 30) - fall back to relative billing
pass
# Fall back to relative billing cycle
if plan.billing_cycle == "yearly":
end_date = start_date + timedelta(days=365)
elif plan.billing_cycle == "quarterly":
end_date = start_date + timedelta(days=90)
elif plan.billing_cycle == "monthly":
end_date = start_date + timedelta(days=30)
elif plan.billing_cycle == "lifetime":
# Lifetime membership: set end date 100 years in the future
end_date = start_date + timedelta(days=365 * 100)
else:
# Default to yearly
end_date = start_date + timedelta(days=365)
return (start_date, end_date)
def get_stripe_interval(billing_cycle: str) -> str:
"""
Map billing_cycle to Stripe recurring interval.
Args:
billing_cycle: Plan billing cycle (yearly, monthly, quarterly, lifetime, custom)
Returns:
str: Stripe interval ("year", "month", or None for one-time)
Examples:
>>> get_stripe_interval("yearly")
"year"
>>> get_stripe_interval("monthly")
"month"
>>> get_stripe_interval("quarterly")
"month" # Will use interval_count=3
>>> get_stripe_interval("lifetime")
None # One-time payment
"""
if billing_cycle in ["yearly", "custom"]:
return "year"
elif billing_cycle in ["monthly", "quarterly"]:
return "month"
elif billing_cycle == "lifetime":
return None # One-time payment, not recurring
else:
# Default to year
return "year"
def create_stripe_price( def create_stripe_price(
product_name: str, product_name: str,
price_cents: int, price_cents: int,

494
server.py
View File

@@ -173,10 +173,38 @@ class UserResponse(BaseModel):
subscription_start_date: Optional[datetime] = None subscription_start_date: Optional[datetime] = None
subscription_end_date: Optional[datetime] = None subscription_end_date: Optional[datetime] = None
subscription_status: Optional[str] = None subscription_status: Optional[str] = None
# Partner information
partner_first_name: Optional[str] = None
partner_last_name: Optional[str] = None
partner_is_member: Optional[bool] = None
partner_plan_to_become_member: Optional[bool] = None
# Newsletter preferences
newsletter_publish_name: Optional[bool] = None
newsletter_publish_photo: Optional[bool] = None
newsletter_publish_birthday: Optional[bool] = None
newsletter_publish_none: Optional[bool] = None
# Volunteer interests
volunteer_interests: Optional[list] = None
# Directory settings
show_in_directory: Optional[bool] = None
directory_email: Optional[str] = None
directory_bio: Optional[str] = None
directory_address: Optional[str] = None
directory_phone: Optional[str] = None
directory_dob: Optional[datetime] = None
directory_partner_name: Optional[str] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
@validator('id', 'status', 'role', pre=True)
def convert_to_string(cls, v):
"""Convert UUID and Enum types to strings"""
if hasattr(v, 'value'):
return v.value
return str(v)
class UpdateProfileRequest(BaseModel): class UpdateProfileRequest(BaseModel):
# Basic personal information
first_name: Optional[str] = None first_name: Optional[str] = None
last_name: Optional[str] = None last_name: Optional[str] = None
phone: Optional[str] = None phone: Optional[str] = None
@@ -185,6 +213,37 @@ class UpdateProfileRequest(BaseModel):
state: Optional[str] = None state: Optional[str] = None
zipcode: Optional[str] = None zipcode: Optional[str] = None
# Partner information
partner_first_name: Optional[str] = None
partner_last_name: Optional[str] = None
partner_is_member: Optional[bool] = None
partner_plan_to_become_member: Optional[bool] = None
# Newsletter preferences
newsletter_publish_name: Optional[bool] = None
newsletter_publish_photo: Optional[bool] = None
newsletter_publish_birthday: Optional[bool] = None
newsletter_publish_none: Optional[bool] = None
# Volunteer interests (array of strings)
volunteer_interests: Optional[list] = None
# Directory settings
show_in_directory: Optional[bool] = None
directory_email: Optional[str] = None
directory_bio: Optional[str] = None
directory_address: Optional[str] = None
directory_phone: Optional[str] = None
directory_dob: Optional[datetime] = None
directory_partner_name: Optional[str] = None
@validator('directory_dob', pre=True)
def empty_str_to_none(cls, v):
"""Convert empty string to None for optional datetime field"""
if v == '' or v is None:
return None
return v
class EnhancedProfileUpdateRequest(BaseModel): class EnhancedProfileUpdateRequest(BaseModel):
"""Members Only - Enhanced profile update with social media and directory settings""" """Members Only - Enhanced profile update with social media and directory settings"""
social_media_facebook: Optional[str] = None social_media_facebook: Optional[str] = None
@@ -199,6 +258,13 @@ class EnhancedProfileUpdateRequest(BaseModel):
directory_dob: Optional[datetime] = None directory_dob: Optional[datetime] = None
directory_partner_name: Optional[str] = None directory_partner_name: Optional[str] = None
@validator('directory_dob', pre=True)
def empty_str_to_none(cls, v):
"""Convert empty string to None for optional datetime field"""
if v == '' or v is None:
return None
return v
class CalendarEventResponse(BaseModel): class CalendarEventResponse(BaseModel):
"""Calendar view response with user RSVP status""" """Calendar view response with user RSVP status"""
id: str id: str
@@ -261,14 +327,21 @@ class UpdateUserStatusRequest(BaseModel):
class ManualPaymentRequest(BaseModel): class ManualPaymentRequest(BaseModel):
plan_id: str = Field(..., description="Subscription plan ID") plan_id: str = Field(..., description="Subscription plan ID")
amount_cents: int = Field(..., description="Payment amount in cents") amount_cents: int = Field(..., ge=3000, description="Payment amount in cents (minimum $30)")
payment_date: datetime = Field(..., description="Date payment was received") payment_date: datetime = Field(..., description="Date payment was received")
payment_method: str = Field(..., description="Payment method: cash, bank_transfer, check, other") 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") 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_start: Optional[datetime] = Field(None, description="Custom subscription start date")
custom_period_end: Optional[datetime] = Field(None, description="Custom subscription end date") custom_period_end: Optional[datetime] = Field(None, description="Custom subscription end date")
override_plan_dates: bool = Field(False, description="Override plan's custom billing cycle with admin-specified dates")
notes: Optional[str] = Field(None, description="Admin notes about payment") notes: Optional[str] = Field(None, description="Admin notes about payment")
@validator('amount_cents')
def validate_amount(cls, v):
if v < 3000:
raise ValueError('Amount must be at least $30 (3000 cents)')
return v
# Auth Routes # Auth Routes
@api_router.post("/auth/register") @api_router.post("/auth/register")
async def register(request: RegisterRequest, db: Session = Depends(get_db)): async def register(request: RegisterRequest, db: Session = Depends(get_db)):
@@ -520,22 +593,8 @@ async def get_me(current_user: User = Depends(get_current_user), db: Session = D
# User Profile Routes # User Profile Routes
@api_router.get("/users/profile", response_model=UserResponse) @api_router.get("/users/profile", response_model=UserResponse)
async def get_profile(current_user: User = Depends(get_current_user)): async def get_profile(current_user: User = Depends(get_current_user)):
return UserResponse( # Use from_attributes to automatically map all User fields to UserResponse
id=str(current_user.id), return UserResponse.model_validate(current_user)
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") @api_router.put("/users/profile")
async def update_profile( async def update_profile(
@@ -543,21 +602,64 @@ async def update_profile(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
if request.first_name: """Update user profile with basic info, partner details, newsletter prefs, volunteer interests, and directory settings."""
# Basic personal information
if request.first_name is not None:
current_user.first_name = request.first_name current_user.first_name = request.first_name
if request.last_name: if request.last_name is not None:
current_user.last_name = request.last_name current_user.last_name = request.last_name
if request.phone: if request.phone is not None:
current_user.phone = request.phone current_user.phone = request.phone
if request.address: if request.address is not None:
current_user.address = request.address current_user.address = request.address
if request.city: if request.city is not None:
current_user.city = request.city current_user.city = request.city
if request.state: if request.state is not None:
current_user.state = request.state current_user.state = request.state
if request.zipcode: if request.zipcode is not None:
current_user.zipcode = request.zipcode current_user.zipcode = request.zipcode
# Partner information
if request.partner_first_name is not None:
current_user.partner_first_name = request.partner_first_name
if request.partner_last_name is not None:
current_user.partner_last_name = request.partner_last_name
if request.partner_is_member is not None:
current_user.partner_is_member = request.partner_is_member
if request.partner_plan_to_become_member is not None:
current_user.partner_plan_to_become_member = request.partner_plan_to_become_member
# Newsletter preferences
if request.newsletter_publish_name is not None:
current_user.newsletter_publish_name = request.newsletter_publish_name
if request.newsletter_publish_photo is not None:
current_user.newsletter_publish_photo = request.newsletter_publish_photo
if request.newsletter_publish_birthday is not None:
current_user.newsletter_publish_birthday = request.newsletter_publish_birthday
if request.newsletter_publish_none is not None:
current_user.newsletter_publish_none = request.newsletter_publish_none
# Volunteer interests (array)
if request.volunteer_interests is not None:
current_user.volunteer_interests = request.volunteer_interests
# Directory settings
if request.show_in_directory is not None:
current_user.show_in_directory = request.show_in_directory
if request.directory_email is not None:
current_user.directory_email = request.directory_email
if request.directory_bio is not None:
current_user.directory_bio = request.directory_bio
if request.directory_address is not None:
current_user.directory_address = request.directory_address
if request.directory_phone is not None:
current_user.directory_phone = request.directory_phone
if request.directory_dob is not None:
current_user.directory_dob = request.directory_dob
if request.directory_partner_name is not None:
current_user.directory_partner_name = request.directory_partner_name
current_user.updated_at = datetime.now(timezone.utc) current_user.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
@@ -567,6 +669,72 @@ async def update_profile(
# ==================== MEMBERS ONLY ROUTES ==================== # ==================== MEMBERS ONLY ROUTES ====================
# Member Directory Routes
@api_router.get("/members/directory")
async def get_member_directory(
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db)
):
"""Get list of all members who opted into the directory"""
directory_members = db.query(User).filter(
User.show_in_directory == True,
User.role == UserRole.member,
User.status == UserStatus.active
).all()
return [{
"id": str(member.id),
"first_name": member.first_name,
"last_name": member.last_name,
"profile_photo_url": member.profile_photo_url,
"directory_email": member.directory_email,
"directory_bio": member.directory_bio,
"directory_address": member.directory_address,
"directory_phone": member.directory_phone,
"directory_dob": member.directory_dob,
"directory_partner_name": member.directory_partner_name,
"volunteer_interests": member.volunteer_interests or [],
"social_media_facebook": member.social_media_facebook,
"social_media_instagram": member.social_media_instagram,
"social_media_twitter": member.social_media_twitter,
"social_media_linkedin": member.social_media_linkedin
} for member in directory_members]
@api_router.get("/members/directory/{user_id}")
async def get_directory_member_profile(
user_id: str,
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db)
):
"""Get public directory profile of a specific member"""
member = db.query(User).filter(
User.id == user_id,
User.show_in_directory == True,
User.role == UserRole.member,
User.status == UserStatus.active
).first()
if not member:
raise HTTPException(status_code=404, detail="Member not found in directory")
return {
"id": str(member.id),
"first_name": member.first_name,
"last_name": member.last_name,
"profile_photo_url": member.profile_photo_url,
"directory_email": member.directory_email,
"directory_bio": member.directory_bio,
"directory_address": member.directory_address,
"directory_phone": member.directory_phone,
"directory_dob": member.directory_dob,
"directory_partner_name": member.directory_partner_name,
"volunteer_interests": member.volunteer_interests or [],
"social_media_facebook": member.social_media_facebook,
"social_media_instagram": member.social_media_instagram,
"social_media_twitter": member.social_media_twitter,
"social_media_linkedin": member.social_media_linkedin
}
# Enhanced Profile Routes (Active Members Only) # Enhanced Profile Routes (Active Members Only)
@api_router.get("/members/profile") @api_router.get("/members/profile")
async def get_enhanced_profile( async def get_enhanced_profile(
@@ -1717,31 +1885,34 @@ async def activate_payment_manually(
if not plan: if not plan:
raise HTTPException(status_code=404, detail="Subscription plan not found") raise HTTPException(status_code=404, detail="Subscription plan not found")
# 4. Calculate subscription period # 4. Validate amount against plan minimum
if request.use_custom_period: if request.amount_cents < plan.minimum_price_cents:
# Use admin-specified custom dates raise HTTPException(
status_code=400,
detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}"
)
# 5. Calculate donation split
base_amount = plan.minimum_price_cents
donation_amount = request.amount_cents - base_amount
# 6. Calculate subscription period
from payment_service import calculate_subscription_period
if request.use_custom_period or request.override_plan_dates:
# Admin-specified custom dates override everything
if not request.custom_period_start or not request.custom_period_end: if not request.custom_period_start or not request.custom_period_end:
raise HTTPException( raise HTTPException(
status_code=400, status_code=400,
detail="Custom period start and end dates are required when use_custom_period is true" detail="Custom period start and end dates are required when use_custom_period or override_plan_dates is true"
) )
period_start = request.custom_period_start period_start = request.custom_period_start
period_end = request.custom_period_end period_end = request.custom_period_end
else: else:
# Use plan's billing cycle # Use plan's custom cycle or billing cycle
period_start = datetime.now(timezone.utc) period_start, period_end = calculate_subscription_period(plan)
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) # 7. Create subscription record (manual payment) with donation tracking
subscription = Subscription( subscription = Subscription(
user_id=user.id, user_id=user.id,
plan_id=plan.id, plan_id=plan.id,
@@ -1751,6 +1922,8 @@ async def activate_payment_manually(
start_date=period_start, start_date=period_start,
end_date=period_end, end_date=period_end,
amount_paid_cents=request.amount_cents, amount_paid_cents=request.amount_cents,
base_subscription_cents=base_amount,
donation_cents=donation_amount,
payment_method=request.payment_method, payment_method=request.payment_method,
manual_payment=True, manual_payment=True,
manual_payment_notes=request.notes, manual_payment_notes=request.notes,
@@ -2031,22 +2204,60 @@ async def delete_event(
# Pydantic model for checkout request # Pydantic model for checkout request
class CheckoutRequest(BaseModel): class CheckoutRequest(BaseModel):
plan_id: str plan_id: str
amount_cents: int = Field(..., ge=3000, description="Total amount in cents (minimum $30)")
@validator('amount_cents')
def validate_amount(cls, v):
if v < 3000:
raise ValueError('Amount must be at least $30 (3000 cents)')
return v
# Pydantic model for plan CRUD # Pydantic model for plan CRUD
class PlanCreateRequest(BaseModel): class PlanCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=100) name: str = Field(min_length=1, max_length=100)
description: Optional[str] = Field(None, max_length=500) description: Optional[str] = Field(None, max_length=500)
price_cents: int = Field(ge=0, le=100000000) price_cents: int = Field(ge=0, le=100000000) # Legacy field, kept for backward compatibility
billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime"] billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime", "custom"]
stripe_price_id: Optional[str] = None stripe_price_id: Optional[str] = None # Deprecated, no longer required
active: bool = True active: bool = True
# Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31)
custom_cycle_enabled: bool = False
custom_cycle_start_month: Optional[int] = Field(None, ge=1, le=12)
custom_cycle_start_day: Optional[int] = Field(None, ge=1, le=31)
custom_cycle_end_month: Optional[int] = Field(None, ge=1, le=12)
custom_cycle_end_day: Optional[int] = Field(None, ge=1, le=31)
# Dynamic pricing fields
minimum_price_cents: int = Field(3000, ge=3000, le=100000000) # $30 minimum
suggested_price_cents: Optional[int] = Field(None, ge=3000, le=100000000)
allow_donation: bool = True
@validator('name') @validator('name')
def validate_name(cls, v): def validate_name(cls, v):
if not v.strip(): if not v.strip():
raise ValueError('Name cannot be empty or whitespace') raise ValueError('Name cannot be empty or whitespace')
return v.strip() return v.strip()
@validator('custom_cycle_start_month', 'custom_cycle_end_month')
def validate_months(cls, v):
if v is not None and (v < 1 or v > 12):
raise ValueError('Month must be between 1 and 12')
return v
@validator('custom_cycle_start_day', 'custom_cycle_end_day')
def validate_days(cls, v):
if v is not None and (v < 1 or v > 31):
raise ValueError('Day must be between 1 and 31')
return v
@validator('suggested_price_cents')
def validate_suggested_price(cls, v, values):
if v is not None and 'minimum_price_cents' in values:
if v < values['minimum_price_cents']:
raise ValueError('Suggested price must be >= minimum price')
return v
@api_router.get("/subscriptions/plans") @api_router.get("/subscriptions/plans")
async def get_subscription_plans(db: Session = Depends(get_db)): async def get_subscription_plans(db: Session = Depends(get_db)):
"""Get all active subscription plans.""" """Get all active subscription plans."""
@@ -2132,13 +2343,36 @@ async def create_plan(
detail="A plan with this name already exists" detail="A plan with this name already exists"
) )
# Validate custom cycle dates if enabled
if request.custom_cycle_enabled:
if not all([
request.custom_cycle_start_month,
request.custom_cycle_start_day,
request.custom_cycle_end_month,
request.custom_cycle_end_day
]):
raise HTTPException(
status_code=400,
detail="All custom cycle date fields must be provided when custom_cycle_enabled is true"
)
plan = SubscriptionPlan( plan = SubscriptionPlan(
name=request.name, name=request.name,
description=request.description, description=request.description,
price_cents=request.price_cents, price_cents=request.price_cents, # Legacy field
billing_cycle=request.billing_cycle, billing_cycle=request.billing_cycle,
stripe_price_id=request.stripe_price_id, stripe_price_id=request.stripe_price_id, # Deprecated
active=request.active active=request.active,
# Custom billing cycle fields
custom_cycle_enabled=request.custom_cycle_enabled,
custom_cycle_start_month=request.custom_cycle_start_month,
custom_cycle_start_day=request.custom_cycle_start_day,
custom_cycle_end_month=request.custom_cycle_end_month,
custom_cycle_end_day=request.custom_cycle_end_day,
# Dynamic pricing fields
minimum_price_cents=request.minimum_price_cents,
suggested_price_cents=request.suggested_price_cents,
allow_donation=request.allow_donation
) )
db.add(plan) db.add(plan)
@@ -2155,6 +2389,14 @@ async def create_plan(
"billing_cycle": plan.billing_cycle, "billing_cycle": plan.billing_cycle,
"stripe_price_id": plan.stripe_price_id, "stripe_price_id": plan.stripe_price_id,
"active": plan.active, "active": plan.active,
"custom_cycle_enabled": plan.custom_cycle_enabled,
"custom_cycle_start_month": plan.custom_cycle_start_month,
"custom_cycle_start_day": plan.custom_cycle_start_day,
"custom_cycle_end_month": plan.custom_cycle_end_month,
"custom_cycle_end_day": plan.custom_cycle_end_day,
"minimum_price_cents": plan.minimum_price_cents,
"suggested_price_cents": plan.suggested_price_cents,
"allow_donation": plan.allow_donation,
"subscriber_count": 0, "subscriber_count": 0,
"created_at": plan.created_at, "created_at": plan.created_at,
"updated_at": plan.updated_at "updated_at": plan.updated_at
@@ -2184,13 +2426,36 @@ async def update_plan(
detail="A plan with this name already exists" detail="A plan with this name already exists"
) )
# Validate custom cycle dates if enabled
if request.custom_cycle_enabled:
if not all([
request.custom_cycle_start_month,
request.custom_cycle_start_day,
request.custom_cycle_end_month,
request.custom_cycle_end_day
]):
raise HTTPException(
status_code=400,
detail="All custom cycle date fields must be provided when custom_cycle_enabled is true"
)
# Update fields # Update fields
plan.name = request.name plan.name = request.name
plan.description = request.description plan.description = request.description
plan.price_cents = request.price_cents plan.price_cents = request.price_cents # Legacy field
plan.billing_cycle = request.billing_cycle plan.billing_cycle = request.billing_cycle
plan.stripe_price_id = request.stripe_price_id plan.stripe_price_id = request.stripe_price_id # Deprecated
plan.active = request.active plan.active = request.active
# Custom billing cycle fields
plan.custom_cycle_enabled = request.custom_cycle_enabled
plan.custom_cycle_start_month = request.custom_cycle_start_month
plan.custom_cycle_start_day = request.custom_cycle_start_day
plan.custom_cycle_end_month = request.custom_cycle_end_month
plan.custom_cycle_end_day = request.custom_cycle_end_day
# Dynamic pricing fields
plan.minimum_price_cents = request.minimum_price_cents
plan.suggested_price_cents = request.suggested_price_cents
plan.allow_donation = request.allow_donation
plan.updated_at = datetime.now(timezone.utc) plan.updated_at = datetime.now(timezone.utc)
db.commit() db.commit()
@@ -2710,7 +2975,7 @@ async def create_checkout(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
db: Session = Depends(get_db) db: Session = Depends(get_db)
): ):
"""Create Stripe Checkout session for subscription payment.""" """Create Stripe Checkout session with dynamic pricing and donation tracking."""
# Get plan # Get plan
plan = db.query(SubscriptionPlan).filter( plan = db.query(SubscriptionPlan).filter(
@@ -2723,24 +2988,110 @@ async def create_checkout(
if not plan.active: if not plan.active:
raise HTTPException(status_code=400, detail="This plan is no longer available for subscription") raise HTTPException(status_code=400, detail="This plan is no longer available for subscription")
if not plan.stripe_price_id: # Validate amount against plan minimum
raise HTTPException(status_code=400, detail="Plan is not configured for payment") if request.amount_cents < plan.minimum_price_cents:
raise HTTPException(
status_code=400,
detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}"
)
# Calculate donation split
base_amount = plan.minimum_price_cents
donation_amount = request.amount_cents - base_amount
# Check if plan allows donations
if donation_amount > 0 and not plan.allow_donation:
raise HTTPException(
status_code=400,
detail="This plan does not accept donations above the minimum price"
)
# Get frontend URL from env # Get frontend URL from env
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
try: try:
# Create checkout session # Build line items for Stripe checkout
session = create_checkout_session( line_items = []
user_id=current_user.id,
user_email=current_user.email, # Add base subscription line item with dynamic pricing
plan_id=plan.id, from payment_service import get_stripe_interval
stripe_price_id=plan.stripe_price_id, stripe_interval = get_stripe_interval(plan.billing_cycle)
if stripe_interval: # Recurring subscription
line_items.append({
"price_data": {
"currency": "usd",
"unit_amount": base_amount,
"recurring": {"interval": stripe_interval},
"product_data": {
"name": plan.name,
"description": plan.description or f"{plan.name} membership"
}
},
"quantity": 1
})
else: # One-time payment (lifetime)
line_items.append({
"price_data": {
"currency": "usd",
"unit_amount": base_amount,
"product_data": {
"name": plan.name,
"description": plan.description or f"{plan.name} membership"
}
},
"quantity": 1
})
# Add donation line item if applicable
if donation_amount > 0:
line_items.append({
"price_data": {
"currency": "usd",
"unit_amount": donation_amount,
"product_data": {
"name": "Donation",
"description": f"Additional donation to support {plan.name}"
}
},
"quantity": 1
})
# Create Stripe Checkout Session
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
mode = "subscription" if stripe_interval else "payment"
session = stripe.checkout.Session.create(
customer_email=current_user.email,
payment_method_types=["card"],
line_items=line_items,
mode=mode,
success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}", success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}",
cancel_url=f"{frontend_url}/payment-cancel" cancel_url=f"{frontend_url}/payment-cancel",
metadata={
"user_id": str(current_user.id),
"plan_id": str(plan.id),
"base_amount": str(base_amount),
"donation_amount": str(donation_amount),
"total_amount": str(request.amount_cents)
},
subscription_data={
"metadata": {
"user_id": str(current_user.id),
"plan_id": str(plan.id),
"base_amount": str(base_amount),
"donation_amount": str(donation_amount)
}
} if mode == "subscription" else None
) )
return {"checkout_url": session["url"]} return {"checkout_url": session.url}
except stripe.error.StripeError as e:
logger.error(f"Stripe error creating checkout session: {str(e)}")
raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}")
except Exception as e: except Exception as e:
logger.error(f"Error creating checkout session: {str(e)}") logger.error(f"Error creating checkout session: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create checkout session") raise HTTPException(status_code=500, detail="Failed to create checkout session")
@@ -2770,6 +3121,9 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
# Get metadata # Get metadata
user_id = session["metadata"].get("user_id") user_id = session["metadata"].get("user_id")
plan_id = session["metadata"].get("plan_id") plan_id = session["metadata"].get("plan_id")
base_amount = int(session["metadata"].get("base_amount", 0))
donation_amount = int(session["metadata"].get("donation_amount", 0))
total_amount = int(session["metadata"].get("total_amount", session.get("amount_total", 0)))
if not user_id or not plan_id: if not user_id or not plan_id:
logger.error("Missing user_id or plan_id in webhook metadata") logger.error("Missing user_id or plan_id in webhook metadata")
@@ -2786,16 +3140,23 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
).first() ).first()
if not existing_subscription: if not existing_subscription:
# Create subscription record # Calculate subscription period using custom billing cycle if enabled
from payment_service import calculate_subscription_period
start_date, end_date = calculate_subscription_period(plan)
# Create subscription record with donation tracking
subscription = Subscription( subscription = Subscription(
user_id=user.id, user_id=user.id,
plan_id=plan.id, plan_id=plan.id,
stripe_subscription_id=session.get("subscription"), stripe_subscription_id=session.get("subscription"),
stripe_customer_id=session.get("customer"), stripe_customer_id=session.get("customer"),
status=SubscriptionStatus.active, status=SubscriptionStatus.active,
start_date=datetime.now(timezone.utc), start_date=start_date,
end_date=get_subscription_end_date(plan.billing_cycle), end_date=end_date,
amount_paid_cents=session.get("amount_total", plan.price_cents) amount_paid_cents=total_amount,
base_subscription_cents=base_amount or plan.minimum_price_cents,
donation_cents=donation_amount,
payment_method="stripe"
) )
db.add(subscription) db.add(subscription)
@@ -2806,7 +3167,10 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
db.commit() db.commit()
logger.info(f"Subscription created for user {user.email}") logger.info(
f"Subscription created for user {user.email}: "
f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}"
)
else: else:
logger.info(f"Subscription already exists for session {session.get('id')}") logger.info(f"Subscription already exists for session {session.get('id')}")
else: else: