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)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
name = Column(String, nullable=False)
|
name = Column(String, nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
price_cents = Column(Integer, nullable=False) # Price in cents
|
price_cents = Column(Integer, nullable=False) # Price in cents (legacy, kept for backward compatibility)
|
||||||
billing_cycle = Column(String, default="yearly", nullable=False) # yearly, monthly, etc.
|
billing_cycle = Column(String, default="yearly", nullable=False) # yearly, monthly, quarterly, lifetime, custom
|
||||||
stripe_price_id = Column(String, nullable=True) # Stripe Price ID
|
stripe_price_id = Column(String, nullable=True) # Stripe Price ID (legacy, deprecated)
|
||||||
active = Column(Boolean, default=True)
|
active = Column(Boolean, default=True)
|
||||||
|
|
||||||
|
# Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31)
|
||||||
|
custom_cycle_enabled = Column(Boolean, default=False, nullable=False)
|
||||||
|
custom_cycle_start_month = Column(Integer, nullable=True) # 1-12
|
||||||
|
custom_cycle_start_day = Column(Integer, nullable=True) # 1-31
|
||||||
|
custom_cycle_end_month = Column(Integer, nullable=True) # 1-12
|
||||||
|
custom_cycle_end_day = Column(Integer, nullable=True) # 1-31
|
||||||
|
|
||||||
|
# Dynamic pricing fields
|
||||||
|
minimum_price_cents = Column(Integer, default=3000, nullable=False) # $30 minimum
|
||||||
|
suggested_price_cents = Column(Integer, nullable=True) # Suggested price (can be higher than minimum)
|
||||||
|
allow_donation = Column(Boolean, default=True, nullable=False) # Allow members to add donations
|
||||||
|
|
||||||
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
|
||||||
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
@@ -164,7 +177,12 @@ class Subscription(Base):
|
|||||||
status = Column(SQLEnum(SubscriptionStatus), default=SubscriptionStatus.active, nullable=False)
|
status = Column(SQLEnum(SubscriptionStatus), default=SubscriptionStatus.active, nullable=False)
|
||||||
start_date = Column(DateTime, nullable=False)
|
start_date = Column(DateTime, nullable=False)
|
||||||
end_date = Column(DateTime, nullable=True)
|
end_date = Column(DateTime, nullable=True)
|
||||||
amount_paid_cents = Column(Integer, nullable=True) # Amount paid in cents
|
amount_paid_cents = Column(Integer, nullable=True) # Total amount paid in cents (base + donation)
|
||||||
|
|
||||||
|
# Donation tracking fields (for transparency and tax reporting)
|
||||||
|
base_subscription_cents = Column(Integer, nullable=False) # Plan base price (minimum)
|
||||||
|
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
|
||||||
|
# Note: amount_paid_cents = base_subscription_cents + donation_cents
|
||||||
|
|
||||||
# Manual payment fields
|
# Manual payment fields
|
||||||
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment
|
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment
|
||||||
|
|||||||
@@ -122,6 +122,131 @@ def get_subscription_end_date(billing_cycle: str = "yearly") -> datetime:
|
|||||||
return now + timedelta(days=365)
|
return now + timedelta(days=365)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_subscription_period(plan, start_date=None, admin_override_dates=None):
|
||||||
|
"""
|
||||||
|
Calculate subscription start and end dates based on plan's custom cycle or billing_cycle.
|
||||||
|
|
||||||
|
Supports three scenarios:
|
||||||
|
1. Plan with custom billing cycle (e.g., Jan 1 - Dec 31 recurring annually)
|
||||||
|
2. Admin-overridden custom dates for manual activation
|
||||||
|
3. Standard relative billing cycle (30/90/365 days from start_date)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plan: SubscriptionPlan object with custom_cycle fields
|
||||||
|
start_date: Optional custom start date (defaults to now)
|
||||||
|
admin_override_dates: Optional dict with {'start_date': datetime, 'end_date': datetime}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple: (start_date, end_date) as datetime objects
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Plan with Jan 1 - Dec 31 custom cycle, subscribing on May 15, 2025
|
||||||
|
>>> calculate_subscription_period(plan)
|
||||||
|
(datetime(2025, 5, 15), datetime(2025, 12, 31))
|
||||||
|
|
||||||
|
# Plan with Jul 1 - Jun 30 fiscal year cycle, subscribing on Aug 20, 2025
|
||||||
|
>>> calculate_subscription_period(plan)
|
||||||
|
(datetime(2025, 8, 20), datetime(2026, 6, 30))
|
||||||
|
|
||||||
|
# Admin override for custom dates
|
||||||
|
>>> calculate_subscription_period(plan, admin_override_dates={'start_date': ..., 'end_date': ...})
|
||||||
|
(custom_start, custom_end)
|
||||||
|
"""
|
||||||
|
# Admin override takes precedence
|
||||||
|
if admin_override_dates:
|
||||||
|
return (admin_override_dates['start_date'], admin_override_dates['end_date'])
|
||||||
|
|
||||||
|
# Default start date to now if not provided
|
||||||
|
if start_date is None:
|
||||||
|
start_date = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# Check if plan uses custom billing cycle
|
||||||
|
if plan.custom_cycle_enabled and plan.custom_cycle_start_month and plan.custom_cycle_start_day:
|
||||||
|
# Calculate end date based on recurring date range
|
||||||
|
current_year = start_date.year
|
||||||
|
|
||||||
|
# Create end date for current cycle
|
||||||
|
try:
|
||||||
|
# Check if this is a year-spanning cycle (e.g., Jul 1 - Jun 30)
|
||||||
|
year_spanning = plan.custom_cycle_end_month < plan.custom_cycle_start_month
|
||||||
|
|
||||||
|
if year_spanning:
|
||||||
|
# Fiscal year scenario: determine if we're in current or next fiscal year
|
||||||
|
cycle_start_this_year = datetime(current_year, plan.custom_cycle_start_month,
|
||||||
|
plan.custom_cycle_start_day, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
if start_date >= cycle_start_this_year:
|
||||||
|
# We're after the start of the current fiscal year
|
||||||
|
end_date = datetime(current_year + 1, plan.custom_cycle_end_month,
|
||||||
|
plan.custom_cycle_end_day, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
# We're before the start, so we're in the previous fiscal year
|
||||||
|
end_date = datetime(current_year, plan.custom_cycle_end_month,
|
||||||
|
plan.custom_cycle_end_day, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
# Calendar-aligned cycle (e.g., Jan 1 - Dec 31)
|
||||||
|
end_date = datetime(current_year, plan.custom_cycle_end_month,
|
||||||
|
plan.custom_cycle_end_day, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
# If end date has already passed this year, use next year's end date
|
||||||
|
if end_date < start_date:
|
||||||
|
end_date = datetime(current_year + 1, plan.custom_cycle_end_month,
|
||||||
|
plan.custom_cycle_end_day, 23, 59, 59, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
return (start_date, end_date)
|
||||||
|
|
||||||
|
except ValueError:
|
||||||
|
# Invalid date (e.g., Feb 30) - fall back to relative billing
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to relative billing cycle
|
||||||
|
if plan.billing_cycle == "yearly":
|
||||||
|
end_date = start_date + timedelta(days=365)
|
||||||
|
elif plan.billing_cycle == "quarterly":
|
||||||
|
end_date = start_date + timedelta(days=90)
|
||||||
|
elif plan.billing_cycle == "monthly":
|
||||||
|
end_date = start_date + timedelta(days=30)
|
||||||
|
elif plan.billing_cycle == "lifetime":
|
||||||
|
# Lifetime membership: set end date 100 years in the future
|
||||||
|
end_date = start_date + timedelta(days=365 * 100)
|
||||||
|
else:
|
||||||
|
# Default to yearly
|
||||||
|
end_date = start_date + timedelta(days=365)
|
||||||
|
|
||||||
|
return (start_date, end_date)
|
||||||
|
|
||||||
|
|
||||||
|
def get_stripe_interval(billing_cycle: str) -> str:
|
||||||
|
"""
|
||||||
|
Map billing_cycle to Stripe recurring interval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
billing_cycle: Plan billing cycle (yearly, monthly, quarterly, lifetime, custom)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: Stripe interval ("year", "month", or None for one-time)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
>>> get_stripe_interval("yearly")
|
||||||
|
"year"
|
||||||
|
>>> get_stripe_interval("monthly")
|
||||||
|
"month"
|
||||||
|
>>> get_stripe_interval("quarterly")
|
||||||
|
"month" # Will use interval_count=3
|
||||||
|
>>> get_stripe_interval("lifetime")
|
||||||
|
None # One-time payment
|
||||||
|
"""
|
||||||
|
if billing_cycle in ["yearly", "custom"]:
|
||||||
|
return "year"
|
||||||
|
elif billing_cycle in ["monthly", "quarterly"]:
|
||||||
|
return "month"
|
||||||
|
elif billing_cycle == "lifetime":
|
||||||
|
return None # One-time payment, not recurring
|
||||||
|
else:
|
||||||
|
# Default to year
|
||||||
|
return "year"
|
||||||
|
|
||||||
|
|
||||||
def create_stripe_price(
|
def create_stripe_price(
|
||||||
product_name: str,
|
product_name: str,
|
||||||
price_cents: int,
|
price_cents: int,
|
||||||
|
|||||||
494
server.py
494
server.py
@@ -173,10 +173,38 @@ class UserResponse(BaseModel):
|
|||||||
subscription_start_date: Optional[datetime] = None
|
subscription_start_date: Optional[datetime] = None
|
||||||
subscription_end_date: Optional[datetime] = None
|
subscription_end_date: Optional[datetime] = None
|
||||||
subscription_status: Optional[str] = None
|
subscription_status: Optional[str] = None
|
||||||
|
# Partner information
|
||||||
|
partner_first_name: Optional[str] = None
|
||||||
|
partner_last_name: Optional[str] = None
|
||||||
|
partner_is_member: Optional[bool] = None
|
||||||
|
partner_plan_to_become_member: Optional[bool] = None
|
||||||
|
# Newsletter preferences
|
||||||
|
newsletter_publish_name: Optional[bool] = None
|
||||||
|
newsletter_publish_photo: Optional[bool] = None
|
||||||
|
newsletter_publish_birthday: Optional[bool] = None
|
||||||
|
newsletter_publish_none: Optional[bool] = None
|
||||||
|
# Volunteer interests
|
||||||
|
volunteer_interests: Optional[list] = None
|
||||||
|
# Directory settings
|
||||||
|
show_in_directory: Optional[bool] = None
|
||||||
|
directory_email: Optional[str] = None
|
||||||
|
directory_bio: Optional[str] = None
|
||||||
|
directory_address: Optional[str] = None
|
||||||
|
directory_phone: Optional[str] = None
|
||||||
|
directory_dob: Optional[datetime] = None
|
||||||
|
directory_partner_name: Optional[str] = None
|
||||||
|
|
||||||
model_config = {"from_attributes": True}
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
@validator('id', 'status', 'role', pre=True)
|
||||||
|
def convert_to_string(cls, v):
|
||||||
|
"""Convert UUID and Enum types to strings"""
|
||||||
|
if hasattr(v, 'value'):
|
||||||
|
return v.value
|
||||||
|
return str(v)
|
||||||
|
|
||||||
class UpdateProfileRequest(BaseModel):
|
class UpdateProfileRequest(BaseModel):
|
||||||
|
# Basic personal information
|
||||||
first_name: Optional[str] = None
|
first_name: Optional[str] = None
|
||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
@@ -185,6 +213,37 @@ class UpdateProfileRequest(BaseModel):
|
|||||||
state: Optional[str] = None
|
state: Optional[str] = None
|
||||||
zipcode: Optional[str] = None
|
zipcode: Optional[str] = None
|
||||||
|
|
||||||
|
# Partner information
|
||||||
|
partner_first_name: Optional[str] = None
|
||||||
|
partner_last_name: Optional[str] = None
|
||||||
|
partner_is_member: Optional[bool] = None
|
||||||
|
partner_plan_to_become_member: Optional[bool] = None
|
||||||
|
|
||||||
|
# Newsletter preferences
|
||||||
|
newsletter_publish_name: Optional[bool] = None
|
||||||
|
newsletter_publish_photo: Optional[bool] = None
|
||||||
|
newsletter_publish_birthday: Optional[bool] = None
|
||||||
|
newsletter_publish_none: Optional[bool] = None
|
||||||
|
|
||||||
|
# Volunteer interests (array of strings)
|
||||||
|
volunteer_interests: Optional[list] = None
|
||||||
|
|
||||||
|
# Directory settings
|
||||||
|
show_in_directory: Optional[bool] = None
|
||||||
|
directory_email: Optional[str] = None
|
||||||
|
directory_bio: Optional[str] = None
|
||||||
|
directory_address: Optional[str] = None
|
||||||
|
directory_phone: Optional[str] = None
|
||||||
|
directory_dob: Optional[datetime] = None
|
||||||
|
directory_partner_name: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('directory_dob', pre=True)
|
||||||
|
def empty_str_to_none(cls, v):
|
||||||
|
"""Convert empty string to None for optional datetime field"""
|
||||||
|
if v == '' or v is None:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
class EnhancedProfileUpdateRequest(BaseModel):
|
class EnhancedProfileUpdateRequest(BaseModel):
|
||||||
"""Members Only - Enhanced profile update with social media and directory settings"""
|
"""Members Only - Enhanced profile update with social media and directory settings"""
|
||||||
social_media_facebook: Optional[str] = None
|
social_media_facebook: Optional[str] = None
|
||||||
@@ -199,6 +258,13 @@ class EnhancedProfileUpdateRequest(BaseModel):
|
|||||||
directory_dob: Optional[datetime] = None
|
directory_dob: Optional[datetime] = None
|
||||||
directory_partner_name: Optional[str] = None
|
directory_partner_name: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('directory_dob', pre=True)
|
||||||
|
def empty_str_to_none(cls, v):
|
||||||
|
"""Convert empty string to None for optional datetime field"""
|
||||||
|
if v == '' or v is None:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
class CalendarEventResponse(BaseModel):
|
class CalendarEventResponse(BaseModel):
|
||||||
"""Calendar view response with user RSVP status"""
|
"""Calendar view response with user RSVP status"""
|
||||||
id: str
|
id: str
|
||||||
@@ -261,14 +327,21 @@ class UpdateUserStatusRequest(BaseModel):
|
|||||||
|
|
||||||
class ManualPaymentRequest(BaseModel):
|
class ManualPaymentRequest(BaseModel):
|
||||||
plan_id: str = Field(..., description="Subscription plan ID")
|
plan_id: str = Field(..., description="Subscription plan ID")
|
||||||
amount_cents: int = Field(..., description="Payment amount in cents")
|
amount_cents: int = Field(..., ge=3000, description="Payment amount in cents (minimum $30)")
|
||||||
payment_date: datetime = Field(..., description="Date payment was received")
|
payment_date: datetime = Field(..., description="Date payment was received")
|
||||||
payment_method: str = Field(..., description="Payment method: cash, bank_transfer, check, other")
|
payment_method: str = Field(..., description="Payment method: cash, bank_transfer, check, other")
|
||||||
use_custom_period: bool = Field(False, description="Whether to use custom dates instead of plan's billing cycle")
|
use_custom_period: bool = Field(False, description="Whether to use custom dates instead of plan's billing cycle")
|
||||||
custom_period_start: Optional[datetime] = Field(None, description="Custom subscription start date")
|
custom_period_start: Optional[datetime] = Field(None, description="Custom subscription start date")
|
||||||
custom_period_end: Optional[datetime] = Field(None, description="Custom subscription end date")
|
custom_period_end: Optional[datetime] = Field(None, description="Custom subscription end date")
|
||||||
|
override_plan_dates: bool = Field(False, description="Override plan's custom billing cycle with admin-specified dates")
|
||||||
notes: Optional[str] = Field(None, description="Admin notes about payment")
|
notes: Optional[str] = Field(None, description="Admin notes about payment")
|
||||||
|
|
||||||
|
@validator('amount_cents')
|
||||||
|
def validate_amount(cls, v):
|
||||||
|
if v < 3000:
|
||||||
|
raise ValueError('Amount must be at least $30 (3000 cents)')
|
||||||
|
return v
|
||||||
|
|
||||||
# Auth Routes
|
# Auth Routes
|
||||||
@api_router.post("/auth/register")
|
@api_router.post("/auth/register")
|
||||||
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
async def register(request: RegisterRequest, db: Session = Depends(get_db)):
|
||||||
@@ -520,22 +593,8 @@ async def get_me(current_user: User = Depends(get_current_user), db: Session = D
|
|||||||
# User Profile Routes
|
# User Profile Routes
|
||||||
@api_router.get("/users/profile", response_model=UserResponse)
|
@api_router.get("/users/profile", response_model=UserResponse)
|
||||||
async def get_profile(current_user: User = Depends(get_current_user)):
|
async def get_profile(current_user: User = Depends(get_current_user)):
|
||||||
return UserResponse(
|
# Use from_attributes to automatically map all User fields to UserResponse
|
||||||
id=str(current_user.id),
|
return UserResponse.model_validate(current_user)
|
||||||
email=current_user.email,
|
|
||||||
first_name=current_user.first_name,
|
|
||||||
last_name=current_user.last_name,
|
|
||||||
phone=current_user.phone,
|
|
||||||
address=current_user.address,
|
|
||||||
city=current_user.city,
|
|
||||||
state=current_user.state,
|
|
||||||
zipcode=current_user.zipcode,
|
|
||||||
date_of_birth=current_user.date_of_birth,
|
|
||||||
status=current_user.status.value,
|
|
||||||
role=current_user.role.value,
|
|
||||||
email_verified=current_user.email_verified,
|
|
||||||
created_at=current_user.created_at
|
|
||||||
)
|
|
||||||
|
|
||||||
@api_router.put("/users/profile")
|
@api_router.put("/users/profile")
|
||||||
async def update_profile(
|
async def update_profile(
|
||||||
@@ -543,21 +602,64 @@ async def update_profile(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
if request.first_name:
|
"""Update user profile with basic info, partner details, newsletter prefs, volunteer interests, and directory settings."""
|
||||||
|
|
||||||
|
# Basic personal information
|
||||||
|
if request.first_name is not None:
|
||||||
current_user.first_name = request.first_name
|
current_user.first_name = request.first_name
|
||||||
if request.last_name:
|
if request.last_name is not None:
|
||||||
current_user.last_name = request.last_name
|
current_user.last_name = request.last_name
|
||||||
if request.phone:
|
if request.phone is not None:
|
||||||
current_user.phone = request.phone
|
current_user.phone = request.phone
|
||||||
if request.address:
|
if request.address is not None:
|
||||||
current_user.address = request.address
|
current_user.address = request.address
|
||||||
if request.city:
|
if request.city is not None:
|
||||||
current_user.city = request.city
|
current_user.city = request.city
|
||||||
if request.state:
|
if request.state is not None:
|
||||||
current_user.state = request.state
|
current_user.state = request.state
|
||||||
if request.zipcode:
|
if request.zipcode is not None:
|
||||||
current_user.zipcode = request.zipcode
|
current_user.zipcode = request.zipcode
|
||||||
|
|
||||||
|
# Partner information
|
||||||
|
if request.partner_first_name is not None:
|
||||||
|
current_user.partner_first_name = request.partner_first_name
|
||||||
|
if request.partner_last_name is not None:
|
||||||
|
current_user.partner_last_name = request.partner_last_name
|
||||||
|
if request.partner_is_member is not None:
|
||||||
|
current_user.partner_is_member = request.partner_is_member
|
||||||
|
if request.partner_plan_to_become_member is not None:
|
||||||
|
current_user.partner_plan_to_become_member = request.partner_plan_to_become_member
|
||||||
|
|
||||||
|
# Newsletter preferences
|
||||||
|
if request.newsletter_publish_name is not None:
|
||||||
|
current_user.newsletter_publish_name = request.newsletter_publish_name
|
||||||
|
if request.newsletter_publish_photo is not None:
|
||||||
|
current_user.newsletter_publish_photo = request.newsletter_publish_photo
|
||||||
|
if request.newsletter_publish_birthday is not None:
|
||||||
|
current_user.newsletter_publish_birthday = request.newsletter_publish_birthday
|
||||||
|
if request.newsletter_publish_none is not None:
|
||||||
|
current_user.newsletter_publish_none = request.newsletter_publish_none
|
||||||
|
|
||||||
|
# Volunteer interests (array)
|
||||||
|
if request.volunteer_interests is not None:
|
||||||
|
current_user.volunteer_interests = request.volunteer_interests
|
||||||
|
|
||||||
|
# Directory settings
|
||||||
|
if request.show_in_directory is not None:
|
||||||
|
current_user.show_in_directory = request.show_in_directory
|
||||||
|
if request.directory_email is not None:
|
||||||
|
current_user.directory_email = request.directory_email
|
||||||
|
if request.directory_bio is not None:
|
||||||
|
current_user.directory_bio = request.directory_bio
|
||||||
|
if request.directory_address is not None:
|
||||||
|
current_user.directory_address = request.directory_address
|
||||||
|
if request.directory_phone is not None:
|
||||||
|
current_user.directory_phone = request.directory_phone
|
||||||
|
if request.directory_dob is not None:
|
||||||
|
current_user.directory_dob = request.directory_dob
|
||||||
|
if request.directory_partner_name is not None:
|
||||||
|
current_user.directory_partner_name = request.directory_partner_name
|
||||||
|
|
||||||
current_user.updated_at = datetime.now(timezone.utc)
|
current_user.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -567,6 +669,72 @@ async def update_profile(
|
|||||||
|
|
||||||
# ==================== MEMBERS ONLY ROUTES ====================
|
# ==================== MEMBERS ONLY ROUTES ====================
|
||||||
|
|
||||||
|
# Member Directory Routes
|
||||||
|
@api_router.get("/members/directory")
|
||||||
|
async def get_member_directory(
|
||||||
|
current_user: User = Depends(get_active_member),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get list of all members who opted into the directory"""
|
||||||
|
directory_members = db.query(User).filter(
|
||||||
|
User.show_in_directory == True,
|
||||||
|
User.role == UserRole.member,
|
||||||
|
User.status == UserStatus.active
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"id": str(member.id),
|
||||||
|
"first_name": member.first_name,
|
||||||
|
"last_name": member.last_name,
|
||||||
|
"profile_photo_url": member.profile_photo_url,
|
||||||
|
"directory_email": member.directory_email,
|
||||||
|
"directory_bio": member.directory_bio,
|
||||||
|
"directory_address": member.directory_address,
|
||||||
|
"directory_phone": member.directory_phone,
|
||||||
|
"directory_dob": member.directory_dob,
|
||||||
|
"directory_partner_name": member.directory_partner_name,
|
||||||
|
"volunteer_interests": member.volunteer_interests or [],
|
||||||
|
"social_media_facebook": member.social_media_facebook,
|
||||||
|
"social_media_instagram": member.social_media_instagram,
|
||||||
|
"social_media_twitter": member.social_media_twitter,
|
||||||
|
"social_media_linkedin": member.social_media_linkedin
|
||||||
|
} for member in directory_members]
|
||||||
|
|
||||||
|
@api_router.get("/members/directory/{user_id}")
|
||||||
|
async def get_directory_member_profile(
|
||||||
|
user_id: str,
|
||||||
|
current_user: User = Depends(get_active_member),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""Get public directory profile of a specific member"""
|
||||||
|
member = db.query(User).filter(
|
||||||
|
User.id == user_id,
|
||||||
|
User.show_in_directory == True,
|
||||||
|
User.role == UserRole.member,
|
||||||
|
User.status == UserStatus.active
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not member:
|
||||||
|
raise HTTPException(status_code=404, detail="Member not found in directory")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": str(member.id),
|
||||||
|
"first_name": member.first_name,
|
||||||
|
"last_name": member.last_name,
|
||||||
|
"profile_photo_url": member.profile_photo_url,
|
||||||
|
"directory_email": member.directory_email,
|
||||||
|
"directory_bio": member.directory_bio,
|
||||||
|
"directory_address": member.directory_address,
|
||||||
|
"directory_phone": member.directory_phone,
|
||||||
|
"directory_dob": member.directory_dob,
|
||||||
|
"directory_partner_name": member.directory_partner_name,
|
||||||
|
"volunteer_interests": member.volunteer_interests or [],
|
||||||
|
"social_media_facebook": member.social_media_facebook,
|
||||||
|
"social_media_instagram": member.social_media_instagram,
|
||||||
|
"social_media_twitter": member.social_media_twitter,
|
||||||
|
"social_media_linkedin": member.social_media_linkedin
|
||||||
|
}
|
||||||
|
|
||||||
# Enhanced Profile Routes (Active Members Only)
|
# Enhanced Profile Routes (Active Members Only)
|
||||||
@api_router.get("/members/profile")
|
@api_router.get("/members/profile")
|
||||||
async def get_enhanced_profile(
|
async def get_enhanced_profile(
|
||||||
@@ -1717,31 +1885,34 @@ async def activate_payment_manually(
|
|||||||
if not plan:
|
if not plan:
|
||||||
raise HTTPException(status_code=404, detail="Subscription plan not found")
|
raise HTTPException(status_code=404, detail="Subscription plan not found")
|
||||||
|
|
||||||
# 4. Calculate subscription period
|
# 4. Validate amount against plan minimum
|
||||||
if request.use_custom_period:
|
if request.amount_cents < plan.minimum_price_cents:
|
||||||
# Use admin-specified custom dates
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. Calculate donation split
|
||||||
|
base_amount = plan.minimum_price_cents
|
||||||
|
donation_amount = request.amount_cents - base_amount
|
||||||
|
|
||||||
|
# 6. Calculate subscription period
|
||||||
|
from payment_service import calculate_subscription_period
|
||||||
|
|
||||||
|
if request.use_custom_period or request.override_plan_dates:
|
||||||
|
# Admin-specified custom dates override everything
|
||||||
if not request.custom_period_start or not request.custom_period_end:
|
if not request.custom_period_start or not request.custom_period_end:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail="Custom period start and end dates are required when use_custom_period is true"
|
detail="Custom period start and end dates are required when use_custom_period or override_plan_dates is true"
|
||||||
)
|
)
|
||||||
period_start = request.custom_period_start
|
period_start = request.custom_period_start
|
||||||
period_end = request.custom_period_end
|
period_end = request.custom_period_end
|
||||||
else:
|
else:
|
||||||
# Use plan's billing cycle
|
# Use plan's custom cycle or billing cycle
|
||||||
period_start = datetime.now(timezone.utc)
|
period_start, period_end = calculate_subscription_period(plan)
|
||||||
if plan.billing_cycle == 'monthly':
|
|
||||||
period_end = period_start + timedelta(days=30)
|
|
||||||
elif plan.billing_cycle == 'quarterly':
|
|
||||||
period_end = period_start + timedelta(days=90)
|
|
||||||
elif plan.billing_cycle == 'yearly':
|
|
||||||
period_end = period_start + timedelta(days=365)
|
|
||||||
elif plan.billing_cycle == 'lifetime':
|
|
||||||
period_end = period_start + timedelta(days=36500) # 100 years
|
|
||||||
else:
|
|
||||||
period_end = period_start + timedelta(days=365) # Default 1 year
|
|
||||||
|
|
||||||
# 5. Create subscription record (manual payment)
|
# 7. Create subscription record (manual payment) with donation tracking
|
||||||
subscription = Subscription(
|
subscription = Subscription(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
plan_id=plan.id,
|
plan_id=plan.id,
|
||||||
@@ -1751,6 +1922,8 @@ async def activate_payment_manually(
|
|||||||
start_date=period_start,
|
start_date=period_start,
|
||||||
end_date=period_end,
|
end_date=period_end,
|
||||||
amount_paid_cents=request.amount_cents,
|
amount_paid_cents=request.amount_cents,
|
||||||
|
base_subscription_cents=base_amount,
|
||||||
|
donation_cents=donation_amount,
|
||||||
payment_method=request.payment_method,
|
payment_method=request.payment_method,
|
||||||
manual_payment=True,
|
manual_payment=True,
|
||||||
manual_payment_notes=request.notes,
|
manual_payment_notes=request.notes,
|
||||||
@@ -2031,22 +2204,60 @@ async def delete_event(
|
|||||||
# Pydantic model for checkout request
|
# Pydantic model for checkout request
|
||||||
class CheckoutRequest(BaseModel):
|
class CheckoutRequest(BaseModel):
|
||||||
plan_id: str
|
plan_id: str
|
||||||
|
amount_cents: int = Field(..., ge=3000, description="Total amount in cents (minimum $30)")
|
||||||
|
|
||||||
|
@validator('amount_cents')
|
||||||
|
def validate_amount(cls, v):
|
||||||
|
if v < 3000:
|
||||||
|
raise ValueError('Amount must be at least $30 (3000 cents)')
|
||||||
|
return v
|
||||||
|
|
||||||
# Pydantic model for plan CRUD
|
# Pydantic model for plan CRUD
|
||||||
class PlanCreateRequest(BaseModel):
|
class PlanCreateRequest(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=100)
|
name: str = Field(min_length=1, max_length=100)
|
||||||
description: Optional[str] = Field(None, max_length=500)
|
description: Optional[str] = Field(None, max_length=500)
|
||||||
price_cents: int = Field(ge=0, le=100000000)
|
price_cents: int = Field(ge=0, le=100000000) # Legacy field, kept for backward compatibility
|
||||||
billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime"]
|
billing_cycle: Literal["monthly", "quarterly", "yearly", "lifetime", "custom"]
|
||||||
stripe_price_id: Optional[str] = None
|
stripe_price_id: Optional[str] = None # Deprecated, no longer required
|
||||||
active: bool = True
|
active: bool = True
|
||||||
|
|
||||||
|
# Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31)
|
||||||
|
custom_cycle_enabled: bool = False
|
||||||
|
custom_cycle_start_month: Optional[int] = Field(None, ge=1, le=12)
|
||||||
|
custom_cycle_start_day: Optional[int] = Field(None, ge=1, le=31)
|
||||||
|
custom_cycle_end_month: Optional[int] = Field(None, ge=1, le=12)
|
||||||
|
custom_cycle_end_day: Optional[int] = Field(None, ge=1, le=31)
|
||||||
|
|
||||||
|
# Dynamic pricing fields
|
||||||
|
minimum_price_cents: int = Field(3000, ge=3000, le=100000000) # $30 minimum
|
||||||
|
suggested_price_cents: Optional[int] = Field(None, ge=3000, le=100000000)
|
||||||
|
allow_donation: bool = True
|
||||||
|
|
||||||
@validator('name')
|
@validator('name')
|
||||||
def validate_name(cls, v):
|
def validate_name(cls, v):
|
||||||
if not v.strip():
|
if not v.strip():
|
||||||
raise ValueError('Name cannot be empty or whitespace')
|
raise ValueError('Name cannot be empty or whitespace')
|
||||||
return v.strip()
|
return v.strip()
|
||||||
|
|
||||||
|
@validator('custom_cycle_start_month', 'custom_cycle_end_month')
|
||||||
|
def validate_months(cls, v):
|
||||||
|
if v is not None and (v < 1 or v > 12):
|
||||||
|
raise ValueError('Month must be between 1 and 12')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('custom_cycle_start_day', 'custom_cycle_end_day')
|
||||||
|
def validate_days(cls, v):
|
||||||
|
if v is not None and (v < 1 or v > 31):
|
||||||
|
raise ValueError('Day must be between 1 and 31')
|
||||||
|
return v
|
||||||
|
|
||||||
|
@validator('suggested_price_cents')
|
||||||
|
def validate_suggested_price(cls, v, values):
|
||||||
|
if v is not None and 'minimum_price_cents' in values:
|
||||||
|
if v < values['minimum_price_cents']:
|
||||||
|
raise ValueError('Suggested price must be >= minimum price')
|
||||||
|
return v
|
||||||
|
|
||||||
@api_router.get("/subscriptions/plans")
|
@api_router.get("/subscriptions/plans")
|
||||||
async def get_subscription_plans(db: Session = Depends(get_db)):
|
async def get_subscription_plans(db: Session = Depends(get_db)):
|
||||||
"""Get all active subscription plans."""
|
"""Get all active subscription plans."""
|
||||||
@@ -2132,13 +2343,36 @@ async def create_plan(
|
|||||||
detail="A plan with this name already exists"
|
detail="A plan with this name already exists"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate custom cycle dates if enabled
|
||||||
|
if request.custom_cycle_enabled:
|
||||||
|
if not all([
|
||||||
|
request.custom_cycle_start_month,
|
||||||
|
request.custom_cycle_start_day,
|
||||||
|
request.custom_cycle_end_month,
|
||||||
|
request.custom_cycle_end_day
|
||||||
|
]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="All custom cycle date fields must be provided when custom_cycle_enabled is true"
|
||||||
|
)
|
||||||
|
|
||||||
plan = SubscriptionPlan(
|
plan = SubscriptionPlan(
|
||||||
name=request.name,
|
name=request.name,
|
||||||
description=request.description,
|
description=request.description,
|
||||||
price_cents=request.price_cents,
|
price_cents=request.price_cents, # Legacy field
|
||||||
billing_cycle=request.billing_cycle,
|
billing_cycle=request.billing_cycle,
|
||||||
stripe_price_id=request.stripe_price_id,
|
stripe_price_id=request.stripe_price_id, # Deprecated
|
||||||
active=request.active
|
active=request.active,
|
||||||
|
# Custom billing cycle fields
|
||||||
|
custom_cycle_enabled=request.custom_cycle_enabled,
|
||||||
|
custom_cycle_start_month=request.custom_cycle_start_month,
|
||||||
|
custom_cycle_start_day=request.custom_cycle_start_day,
|
||||||
|
custom_cycle_end_month=request.custom_cycle_end_month,
|
||||||
|
custom_cycle_end_day=request.custom_cycle_end_day,
|
||||||
|
# Dynamic pricing fields
|
||||||
|
minimum_price_cents=request.minimum_price_cents,
|
||||||
|
suggested_price_cents=request.suggested_price_cents,
|
||||||
|
allow_donation=request.allow_donation
|
||||||
)
|
)
|
||||||
|
|
||||||
db.add(plan)
|
db.add(plan)
|
||||||
@@ -2155,6 +2389,14 @@ async def create_plan(
|
|||||||
"billing_cycle": plan.billing_cycle,
|
"billing_cycle": plan.billing_cycle,
|
||||||
"stripe_price_id": plan.stripe_price_id,
|
"stripe_price_id": plan.stripe_price_id,
|
||||||
"active": plan.active,
|
"active": plan.active,
|
||||||
|
"custom_cycle_enabled": plan.custom_cycle_enabled,
|
||||||
|
"custom_cycle_start_month": plan.custom_cycle_start_month,
|
||||||
|
"custom_cycle_start_day": plan.custom_cycle_start_day,
|
||||||
|
"custom_cycle_end_month": plan.custom_cycle_end_month,
|
||||||
|
"custom_cycle_end_day": plan.custom_cycle_end_day,
|
||||||
|
"minimum_price_cents": plan.minimum_price_cents,
|
||||||
|
"suggested_price_cents": plan.suggested_price_cents,
|
||||||
|
"allow_donation": plan.allow_donation,
|
||||||
"subscriber_count": 0,
|
"subscriber_count": 0,
|
||||||
"created_at": plan.created_at,
|
"created_at": plan.created_at,
|
||||||
"updated_at": plan.updated_at
|
"updated_at": plan.updated_at
|
||||||
@@ -2184,13 +2426,36 @@ async def update_plan(
|
|||||||
detail="A plan with this name already exists"
|
detail="A plan with this name already exists"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Validate custom cycle dates if enabled
|
||||||
|
if request.custom_cycle_enabled:
|
||||||
|
if not all([
|
||||||
|
request.custom_cycle_start_month,
|
||||||
|
request.custom_cycle_start_day,
|
||||||
|
request.custom_cycle_end_month,
|
||||||
|
request.custom_cycle_end_day
|
||||||
|
]):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="All custom cycle date fields must be provided when custom_cycle_enabled is true"
|
||||||
|
)
|
||||||
|
|
||||||
# Update fields
|
# Update fields
|
||||||
plan.name = request.name
|
plan.name = request.name
|
||||||
plan.description = request.description
|
plan.description = request.description
|
||||||
plan.price_cents = request.price_cents
|
plan.price_cents = request.price_cents # Legacy field
|
||||||
plan.billing_cycle = request.billing_cycle
|
plan.billing_cycle = request.billing_cycle
|
||||||
plan.stripe_price_id = request.stripe_price_id
|
plan.stripe_price_id = request.stripe_price_id # Deprecated
|
||||||
plan.active = request.active
|
plan.active = request.active
|
||||||
|
# Custom billing cycle fields
|
||||||
|
plan.custom_cycle_enabled = request.custom_cycle_enabled
|
||||||
|
plan.custom_cycle_start_month = request.custom_cycle_start_month
|
||||||
|
plan.custom_cycle_start_day = request.custom_cycle_start_day
|
||||||
|
plan.custom_cycle_end_month = request.custom_cycle_end_month
|
||||||
|
plan.custom_cycle_end_day = request.custom_cycle_end_day
|
||||||
|
# Dynamic pricing fields
|
||||||
|
plan.minimum_price_cents = request.minimum_price_cents
|
||||||
|
plan.suggested_price_cents = request.suggested_price_cents
|
||||||
|
plan.allow_donation = request.allow_donation
|
||||||
plan.updated_at = datetime.now(timezone.utc)
|
plan.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -2710,7 +2975,7 @@ async def create_checkout(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
"""Create Stripe Checkout session for subscription payment."""
|
"""Create Stripe Checkout session with dynamic pricing and donation tracking."""
|
||||||
|
|
||||||
# Get plan
|
# Get plan
|
||||||
plan = db.query(SubscriptionPlan).filter(
|
plan = db.query(SubscriptionPlan).filter(
|
||||||
@@ -2723,24 +2988,110 @@ async def create_checkout(
|
|||||||
if not plan.active:
|
if not plan.active:
|
||||||
raise HTTPException(status_code=400, detail="This plan is no longer available for subscription")
|
raise HTTPException(status_code=400, detail="This plan is no longer available for subscription")
|
||||||
|
|
||||||
if not plan.stripe_price_id:
|
# Validate amount against plan minimum
|
||||||
raise HTTPException(status_code=400, detail="Plan is not configured for payment")
|
if request.amount_cents < plan.minimum_price_cents:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Amount must be at least ${plan.minimum_price_cents / 100:.2f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate donation split
|
||||||
|
base_amount = plan.minimum_price_cents
|
||||||
|
donation_amount = request.amount_cents - base_amount
|
||||||
|
|
||||||
|
# Check if plan allows donations
|
||||||
|
if donation_amount > 0 and not plan.allow_donation:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="This plan does not accept donations above the minimum price"
|
||||||
|
)
|
||||||
|
|
||||||
# Get frontend URL from env
|
# Get frontend URL from env
|
||||||
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Create checkout session
|
# Build line items for Stripe checkout
|
||||||
session = create_checkout_session(
|
line_items = []
|
||||||
user_id=current_user.id,
|
|
||||||
user_email=current_user.email,
|
# Add base subscription line item with dynamic pricing
|
||||||
plan_id=plan.id,
|
from payment_service import get_stripe_interval
|
||||||
stripe_price_id=plan.stripe_price_id,
|
stripe_interval = get_stripe_interval(plan.billing_cycle)
|
||||||
|
|
||||||
|
if stripe_interval: # Recurring subscription
|
||||||
|
line_items.append({
|
||||||
|
"price_data": {
|
||||||
|
"currency": "usd",
|
||||||
|
"unit_amount": base_amount,
|
||||||
|
"recurring": {"interval": stripe_interval},
|
||||||
|
"product_data": {
|
||||||
|
"name": plan.name,
|
||||||
|
"description": plan.description or f"{plan.name} membership"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quantity": 1
|
||||||
|
})
|
||||||
|
else: # One-time payment (lifetime)
|
||||||
|
line_items.append({
|
||||||
|
"price_data": {
|
||||||
|
"currency": "usd",
|
||||||
|
"unit_amount": base_amount,
|
||||||
|
"product_data": {
|
||||||
|
"name": plan.name,
|
||||||
|
"description": plan.description or f"{plan.name} membership"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quantity": 1
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add donation line item if applicable
|
||||||
|
if donation_amount > 0:
|
||||||
|
line_items.append({
|
||||||
|
"price_data": {
|
||||||
|
"currency": "usd",
|
||||||
|
"unit_amount": donation_amount,
|
||||||
|
"product_data": {
|
||||||
|
"name": "Donation",
|
||||||
|
"description": f"Additional donation to support {plan.name}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quantity": 1
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create Stripe Checkout Session
|
||||||
|
import stripe
|
||||||
|
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
|
||||||
|
mode = "subscription" if stripe_interval else "payment"
|
||||||
|
|
||||||
|
session = stripe.checkout.Session.create(
|
||||||
|
customer_email=current_user.email,
|
||||||
|
payment_method_types=["card"],
|
||||||
|
line_items=line_items,
|
||||||
|
mode=mode,
|
||||||
success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}",
|
success_url=f"{frontend_url}/payment-success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||||
cancel_url=f"{frontend_url}/payment-cancel"
|
cancel_url=f"{frontend_url}/payment-cancel",
|
||||||
|
metadata={
|
||||||
|
"user_id": str(current_user.id),
|
||||||
|
"plan_id": str(plan.id),
|
||||||
|
"base_amount": str(base_amount),
|
||||||
|
"donation_amount": str(donation_amount),
|
||||||
|
"total_amount": str(request.amount_cents)
|
||||||
|
},
|
||||||
|
subscription_data={
|
||||||
|
"metadata": {
|
||||||
|
"user_id": str(current_user.id),
|
||||||
|
"plan_id": str(plan.id),
|
||||||
|
"base_amount": str(base_amount),
|
||||||
|
"donation_amount": str(donation_amount)
|
||||||
|
}
|
||||||
|
} if mode == "subscription" else None
|
||||||
)
|
)
|
||||||
|
|
||||||
return {"checkout_url": session["url"]}
|
return {"checkout_url": session.url}
|
||||||
|
|
||||||
|
except stripe.error.StripeError as e:
|
||||||
|
logger.error(f"Stripe error creating checkout session: {str(e)}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating checkout session: {str(e)}")
|
logger.error(f"Error creating checkout session: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail="Failed to create checkout session")
|
raise HTTPException(status_code=500, detail="Failed to create checkout session")
|
||||||
@@ -2770,6 +3121,9 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
# Get metadata
|
# Get metadata
|
||||||
user_id = session["metadata"].get("user_id")
|
user_id = session["metadata"].get("user_id")
|
||||||
plan_id = session["metadata"].get("plan_id")
|
plan_id = session["metadata"].get("plan_id")
|
||||||
|
base_amount = int(session["metadata"].get("base_amount", 0))
|
||||||
|
donation_amount = int(session["metadata"].get("donation_amount", 0))
|
||||||
|
total_amount = int(session["metadata"].get("total_amount", session.get("amount_total", 0)))
|
||||||
|
|
||||||
if not user_id or not plan_id:
|
if not user_id or not plan_id:
|
||||||
logger.error("Missing user_id or plan_id in webhook metadata")
|
logger.error("Missing user_id or plan_id in webhook metadata")
|
||||||
@@ -2786,16 +3140,23 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not existing_subscription:
|
if not existing_subscription:
|
||||||
# Create subscription record
|
# Calculate subscription period using custom billing cycle if enabled
|
||||||
|
from payment_service import calculate_subscription_period
|
||||||
|
start_date, end_date = calculate_subscription_period(plan)
|
||||||
|
|
||||||
|
# Create subscription record with donation tracking
|
||||||
subscription = Subscription(
|
subscription = Subscription(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
plan_id=plan.id,
|
plan_id=plan.id,
|
||||||
stripe_subscription_id=session.get("subscription"),
|
stripe_subscription_id=session.get("subscription"),
|
||||||
stripe_customer_id=session.get("customer"),
|
stripe_customer_id=session.get("customer"),
|
||||||
status=SubscriptionStatus.active,
|
status=SubscriptionStatus.active,
|
||||||
start_date=datetime.now(timezone.utc),
|
start_date=start_date,
|
||||||
end_date=get_subscription_end_date(plan.billing_cycle),
|
end_date=end_date,
|
||||||
amount_paid_cents=session.get("amount_total", plan.price_cents)
|
amount_paid_cents=total_amount,
|
||||||
|
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
||||||
|
donation_cents=donation_amount,
|
||||||
|
payment_method="stripe"
|
||||||
)
|
)
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
|
|
||||||
@@ -2806,7 +3167,10 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
logger.info(f"Subscription created for user {user.email}")
|
logger.info(
|
||||||
|
f"Subscription created for user {user.email}: "
|
||||||
|
f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.info(f"Subscription already exists for session {session.get('id')}")
|
logger.info(f"Subscription already exists for session {session.get('id')}")
|
||||||
else:
|
else:
|
||||||
|
|||||||
Reference in New Issue
Block a user