diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 32fb823..0b9446a 100644 Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ diff --git a/__pycache__/payment_service.cpython-312.pyc b/__pycache__/payment_service.cpython-312.pyc index facceeb..01a6878 100644 Binary files a/__pycache__/payment_service.cpython-312.pyc and b/__pycache__/payment_service.cpython-312.pyc differ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 73f53e8..615b9a9 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/migrate_billing_enhancements.py b/migrate_billing_enhancements.py new file mode 100644 index 0000000..f333e30 --- /dev/null +++ b/migrate_billing_enhancements.py @@ -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() diff --git a/models.py b/models.py index abd7973..0e1fe35 100644 --- a/models.py +++ b/models.py @@ -143,10 +143,23 @@ class SubscriptionPlan(Base): 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 + price_cents = Column(Integer, nullable=False) # Price in cents (legacy, kept for backward compatibility) + billing_cycle = Column(String, default="yearly", nullable=False) # yearly, monthly, quarterly, lifetime, custom + stripe_price_id = Column(String, nullable=True) # Stripe Price ID (legacy, deprecated) 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)) 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) start_date = Column(DateTime, nullable=False) 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 = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment diff --git a/payment_service.py b/payment_service.py index 8886ad6..562ddd2 100644 --- a/payment_service.py +++ b/payment_service.py @@ -122,6 +122,131 @@ def get_subscription_end_date(billing_cycle: str = "yearly") -> datetime: 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( product_name: str, price_cents: int, diff --git a/server.py b/server.py index 710300b..b51cfb1 100644 --- a/server.py +++ b/server.py @@ -173,10 +173,38 @@ class UserResponse(BaseModel): subscription_start_date: Optional[datetime] = None subscription_end_date: Optional[datetime] = 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} + @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): + # Basic personal information first_name: Optional[str] = None last_name: Optional[str] = None phone: Optional[str] = None @@ -185,6 +213,37 @@ class UpdateProfileRequest(BaseModel): state: 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): """Members Only - Enhanced profile update with social media and directory settings""" social_media_facebook: Optional[str] = None @@ -199,6 +258,13 @@ class EnhancedProfileUpdateRequest(BaseModel): 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 CalendarEventResponse(BaseModel): """Calendar view response with user RSVP status""" id: str @@ -261,14 +327,21 @@ class UpdateUserStatusRequest(BaseModel): class ManualPaymentRequest(BaseModel): 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_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") + 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") + @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 @api_router.post("/auth/register") 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 @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 - ) + # Use from_attributes to automatically map all User fields to UserResponse + return UserResponse.model_validate(current_user) @api_router.put("/users/profile") async def update_profile( @@ -543,21 +602,64 @@ async def update_profile( current_user: User = Depends(get_current_user), 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 - if request.last_name: + if request.last_name is not None: current_user.last_name = request.last_name - if request.phone: + if request.phone is not None: current_user.phone = request.phone - if request.address: + if request.address is not None: current_user.address = request.address - if request.city: + if request.city is not None: current_user.city = request.city - if request.state: + if request.state is not None: current_user.state = request.state - if request.zipcode: + if request.zipcode is not None: 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) db.commit() @@ -567,6 +669,72 @@ async def update_profile( # ==================== 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) @api_router.get("/members/profile") async def get_enhanced_profile( @@ -1717,31 +1885,34 @@ async def activate_payment_manually( 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 + # 4. Validate amount against plan minimum + 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}" + ) + + # 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: raise HTTPException( 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_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 + # Use plan's custom cycle or billing cycle + period_start, period_end = calculate_subscription_period(plan) - # 5. Create subscription record (manual payment) + # 7. Create subscription record (manual payment) with donation tracking subscription = Subscription( user_id=user.id, plan_id=plan.id, @@ -1751,6 +1922,8 @@ async def activate_payment_manually( start_date=period_start, end_date=period_end, amount_paid_cents=request.amount_cents, + base_subscription_cents=base_amount, + donation_cents=donation_amount, payment_method=request.payment_method, manual_payment=True, manual_payment_notes=request.notes, @@ -2031,22 +2204,60 @@ async def delete_event( # Pydantic model for checkout request class CheckoutRequest(BaseModel): 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 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 + price_cents: int = Field(ge=0, le=100000000) # Legacy field, kept for backward compatibility + billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime", "custom"] + stripe_price_id: Optional[str] = None # Deprecated, no longer required 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') def validate_name(cls, v): if not v.strip(): raise ValueError('Name cannot be empty or whitespace') 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") async def get_subscription_plans(db: Session = Depends(get_db)): """Get all active subscription plans.""" @@ -2132,13 +2343,36 @@ async def create_plan( 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( name=request.name, description=request.description, - price_cents=request.price_cents, + price_cents=request.price_cents, # Legacy field billing_cycle=request.billing_cycle, - stripe_price_id=request.stripe_price_id, - active=request.active + stripe_price_id=request.stripe_price_id, # Deprecated + 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) @@ -2155,6 +2389,14 @@ async def create_plan( "billing_cycle": plan.billing_cycle, "stripe_price_id": plan.stripe_price_id, "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, "created_at": plan.created_at, "updated_at": plan.updated_at @@ -2184,13 +2426,36 @@ async def update_plan( 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 plan.name = request.name 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.stripe_price_id = request.stripe_price_id + plan.stripe_price_id = request.stripe_price_id # Deprecated 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) db.commit() @@ -2710,7 +2975,7 @@ async def create_checkout( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): - """Create Stripe Checkout session for subscription payment.""" + """Create Stripe Checkout session with dynamic pricing and donation tracking.""" # Get plan plan = db.query(SubscriptionPlan).filter( @@ -2723,24 +2988,110 @@ async def create_checkout( 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") + # Validate amount against plan minimum + 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 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, + # Build line items for Stripe checkout + line_items = [] + + # Add base subscription line item with dynamic pricing + from payment_service import get_stripe_interval + 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}}", - 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: logger.error(f"Error creating checkout session: {str(e)}") 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 user_id = session["metadata"].get("user_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: 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() 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( 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) + start_date=start_date, + end_date=end_date, + 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) @@ -2806,7 +3167,10 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): 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: logger.info(f"Subscription already exists for session {session.get('id')}") else: