Update:- Membership Plan- Donation- Member detail for Member Directory
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
314
migrate_billing_enhancements.py
Normal file
314
migrate_billing_enhancements.py
Normal 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()
|
||||
26
models.py
26
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
|
||||
|
||||
@@ -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,
|
||||
|
||||
494
server.py
494
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:
|
||||
|
||||
Reference in New Issue
Block a user