From e875700b8ed935146f8c4f201ac0f1a98e2434b4 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Thu, 11 Dec 2025 19:28:48 +0700 Subject: [PATCH] Update:- Membership Plan- Donation- Member detail for Member Directory --- __pycache__/models.cpython-312.pyc | Bin 16671 -> 17251 bytes __pycache__/payment_service.cpython-312.pyc | Bin 6118 -> 10398 bytes __pycache__/server.cpython-312.pyc | Bin 125405 -> 138922 bytes migrate_billing_enhancements.py | 314 +++++++++++++ models.py | 26 +- payment_service.py | 125 +++++ server.py | 494 +++++++++++++++++--- 7 files changed, 890 insertions(+), 69 deletions(-) create mode 100644 migrate_billing_enhancements.py diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 32fb823eb7c5d117b13fd731d803f455f07dd447..0b9446ab1dc4aaa44b2ca4efabe72aaa27aef45d 100644 GIT binary patch delta 1301 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 delta 302 zcmbOi_)MSgG%qg~0}vb)Gsu(`p2#P`$Tv~_oU2rdP%3wdKq_k*ONwv{Ym@*dLyA<2 z2vAHkRS+u2oyL(O29y&@5eJG(fYo!Sv8711utW)0GHFV04q}|f!0G?0=Jkm^Gb?9+KNE(D;bJxL2M0>6o*Z2eoARhs$EemkPCD!LovtXV)dO&A512D KY6P-?R006Ed`a~H diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 73f53e86de239a70584f47b4e1541b1b4c0f82ca..615b9a91b9632a08b407172d6ce6d69c0ce47d47 100644 GIT binary patch delta 31049 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 a?3X2-kR^3aJ}JvOBUALtggs};%>NHCLT#J? delta 20613 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 nMCnxEDalf-eGGtoOJ= 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: