13 Commits

Author SHA1 Message Date
kayela
5ab0038c0a Merge branch 'dev' into docker 2026-01-17 13:37:04 -06:00
kayela
38e5f5377a Merge branch 'dev' into docker 2026-01-06 12:31:29 -06:00
kayela
e06f18ce17 Add start script for backend server initialization and update .gitignore 2026-01-06 12:30:26 -06:00
kayela
84285861cc Merge branch 'dev' into docker 2026-01-05 13:01:16 -06:00
kayela
56d1b97261 docker deleted 2026-01-05 12:58:22 -06:00
kayela
6b6173bd5b Refactor docker-compose.yml by removing unnecessary lines and cleaning up formatting 2025-12-26 17:33:40 -06:00
kayela
cf8d38a4a4 Remove database service configuration from docker-compose 2025-12-26 16:50:21 -06:00
kayela
09712e52bb Merge remote-tracking branch 'origin/dev' into docker 2025-12-26 16:47:24 -06:00
kayela
366245acc7 Add database service configuration to docker-compose 2025-12-24 13:00:42 -06:00
kayela
a75bf743f4 Merge remote-tracking branch 'origin/dev' into docker 2025-12-24 12:46:32 -06:00
kayela
fb369977d0 Update compiled Python bytecode for server module 2025-12-24 12:30:09 -06:00
kayela
1ed9aa0994 Merge remote-tracking branch 'origin' into docker 2025-12-19 12:51:56 -06:00
kayela-c
04783f66f1 docker set up 2025-12-13 12:23:28 -06:00
21 changed files with 67 additions and 467 deletions

3
.gitignore vendored
View File

@@ -1,3 +1,5 @@
.env
.venv
# ============================================================================
# Python Backend .gitignore
# For FastAPI + PostgreSQL + Cloudflare R2 + Stripe
@@ -8,6 +10,7 @@
.env.*
!.env.example
.envrc
.sh
# ===== Python =====
# Byte-compiled / optimized / DLL files

20
Dockerfile Normal file
View File

@@ -0,0 +1,20 @@
# Use an official Python image (Linux)
FROM python:3.12-slim
# Set a working directory
WORKDIR /app
# Copy dependency list
COPY requirements.txt .
# Install dependencies
RUN pip3 install --no-cache-dir -r requirements.txt
# Copy the rest of the project
COPY . .
# Expose port (whatever your backend runs on)
EXPOSE 8000
# Run exactly your command
CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,76 +0,0 @@
"""add_stripe_transaction_metadata
Revision ID: 956ea1628264
Revises: ec4cb4a49cde
Create Date: 2026-01-20 22:00:01.806931
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '956ea1628264'
down_revision: Union[str, None] = 'ec4cb4a49cde'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add Stripe transaction metadata to subscriptions table
op.add_column('subscriptions', sa.Column('stripe_payment_intent_id', sa.String(), nullable=True))
op.add_column('subscriptions', sa.Column('stripe_charge_id', sa.String(), nullable=True))
op.add_column('subscriptions', sa.Column('stripe_invoice_id', sa.String(), nullable=True))
op.add_column('subscriptions', sa.Column('payment_completed_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('subscriptions', sa.Column('card_last4', sa.String(4), nullable=True))
op.add_column('subscriptions', sa.Column('card_brand', sa.String(20), nullable=True))
op.add_column('subscriptions', sa.Column('stripe_receipt_url', sa.String(), nullable=True))
# Add indexes for Stripe transaction IDs in subscriptions
op.create_index('idx_subscriptions_payment_intent', 'subscriptions', ['stripe_payment_intent_id'])
op.create_index('idx_subscriptions_charge_id', 'subscriptions', ['stripe_charge_id'])
op.create_index('idx_subscriptions_invoice_id', 'subscriptions', ['stripe_invoice_id'])
# Add Stripe transaction metadata to donations table
op.add_column('donations', sa.Column('stripe_charge_id', sa.String(), nullable=True))
op.add_column('donations', sa.Column('stripe_customer_id', sa.String(), nullable=True))
op.add_column('donations', sa.Column('payment_completed_at', sa.DateTime(timezone=True), nullable=True))
op.add_column('donations', sa.Column('card_last4', sa.String(4), nullable=True))
op.add_column('donations', sa.Column('card_brand', sa.String(20), nullable=True))
op.add_column('donations', sa.Column('stripe_receipt_url', sa.String(), nullable=True))
# Add indexes for Stripe transaction IDs in donations
op.create_index('idx_donations_payment_intent', 'donations', ['stripe_payment_intent_id'])
op.create_index('idx_donations_charge_id', 'donations', ['stripe_charge_id'])
op.create_index('idx_donations_customer_id', 'donations', ['stripe_customer_id'])
def downgrade() -> None:
# Remove indexes from donations
op.drop_index('idx_donations_customer_id', table_name='donations')
op.drop_index('idx_donations_charge_id', table_name='donations')
op.drop_index('idx_donations_payment_intent', table_name='donations')
# Remove columns from donations
op.drop_column('donations', 'stripe_receipt_url')
op.drop_column('donations', 'card_brand')
op.drop_column('donations', 'card_last4')
op.drop_column('donations', 'payment_completed_at')
op.drop_column('donations', 'stripe_customer_id')
op.drop_column('donations', 'stripe_charge_id')
# Remove indexes from subscriptions
op.drop_index('idx_subscriptions_invoice_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_charge_id', table_name='subscriptions')
op.drop_index('idx_subscriptions_payment_intent', table_name='subscriptions')
# Remove columns from subscriptions
op.drop_column('subscriptions', 'stripe_receipt_url')
op.drop_column('subscriptions', 'card_brand')
op.drop_column('subscriptions', 'card_last4')
op.drop_column('subscriptions', 'payment_completed_at')
op.drop_column('subscriptions', 'stripe_invoice_id')
op.drop_column('subscriptions', 'stripe_charge_id')
op.drop_column('subscriptions', 'stripe_payment_intent_id')

14
docker-compose.yml Normal file
View File

@@ -0,0 +1,14 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile # Use Dockerfile.prod for production
ports:
- "8000:8000"
env_file:
- .env
environment:
DATABASE_URL: ${DATABASE_URL}
volumes:
- .:/app # sync code for hot reload

View File

@@ -238,15 +238,6 @@ class Subscription(Base):
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
# Note: amount_paid_cents = base_subscription_cents + donation_cents
# Stripe transaction metadata (for validation and audit)
stripe_payment_intent_id = Column(String, nullable=True, index=True) # Initial payment transaction ID
stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference
stripe_invoice_id = Column(String, nullable=True, index=True) # Invoice reference
payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe
card_last4 = Column(String(4), nullable=True) # Last 4 digits of card
card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc.
stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt
# Manual payment fields
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment
manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment
@@ -278,17 +269,9 @@ class Donation(Base):
# Payment details
stripe_checkout_session_id = Column(String, nullable=True)
stripe_payment_intent_id = Column(String, nullable=True, index=True)
stripe_payment_intent_id = Column(String, nullable=True)
payment_method = Column(String, nullable=True) # card, bank_transfer, etc.
# Stripe transaction metadata (for validation and audit)
stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference
stripe_customer_id = Column(String, nullable=True, index=True) # Customer ID if created
payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe
card_last4 = Column(String(4), nullable=True) # Last 4 digits of card
card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc.
stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt
# Metadata
notes = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))

View File

@@ -31,7 +31,7 @@ motor==3.3.1
msal==1.27.0
mypy==1.18.2
mypy_extensions==1.1.0
numpy==2.3.5
numpy==2.2.6
oauthlib==3.3.1
packaging==25.0
pandas==2.3.3

370
server.py
View File

@@ -227,7 +227,6 @@ class UserResponse(BaseModel):
role: str
email_verified: bool
created_at: datetime
member_since: Optional[datetime] = None # Date when user became active member
# Profile
profile_photo_url: Optional[str] = None
# Subscription info (optional)
@@ -483,31 +482,6 @@ class InviteUserRequest(BaseModel):
last_name: Optional[str] = None
phone: Optional[str] = None
class AdminUpdateUserRequest(BaseModel):
"""Admin-only endpoint for updating user profile fields"""
first_name: Optional[str] = None
last_name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
city: Optional[str] = None
state: Optional[str] = None
zipcode: Optional[str] = None
date_of_birth: Optional[datetime] = None
member_since: Optional[datetime] = 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
referred_by_member_name: Optional[str] = None
@validator('date_of_birth', 'member_since', pre=True)
def empty_str_to_none(cls, v):
"""Convert empty string to None for optional datetime fields"""
if v == '' or v is None:
return None
return v
class InvitationResponse(BaseModel):
id: str
email: str
@@ -1742,75 +1716,6 @@ async def get_my_event_activity(
"total_rsvps": len(rsvps)
}
# ============================================================================
# Member Transaction History Endpoint
# ============================================================================
@api_router.get("/members/transactions")
async def get_member_transactions(
current_user: User = Depends(get_active_member),
db: Session = Depends(get_db)
):
"""
Get current member's transaction history including subscriptions and donations.
Returns both types of transactions sorted by date (newest first).
"""
# Get user's subscriptions with plan details
subscriptions = db.query(Subscription).filter(
Subscription.user_id == current_user.id
).order_by(Subscription.created_at.desc()).all()
subscription_list = []
for sub in subscriptions:
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == sub.plan_id).first()
subscription_list.append({
"id": str(sub.id),
"type": "subscription",
"description": plan.name if plan else "Subscription",
"amount_cents": sub.amount_paid_cents or (sub.base_subscription_cents + sub.donation_cents),
"base_amount_cents": sub.base_subscription_cents,
"donation_cents": sub.donation_cents,
"status": sub.status.value if sub.status else "unknown",
"payment_method": sub.payment_method,
"card_brand": sub.card_brand,
"card_last4": sub.card_last4,
"stripe_receipt_url": sub.stripe_receipt_url,
"created_at": sub.created_at.isoformat() if sub.created_at else None,
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
"start_date": sub.start_date.isoformat() if sub.start_date else None,
"end_date": sub.end_date.isoformat() if sub.end_date else None,
"billing_cycle": plan.billing_cycle if plan else None,
"manual_payment": sub.manual_payment
})
# Get user's donations
donations = db.query(Donation).filter(
Donation.user_id == current_user.id
).order_by(Donation.created_at.desc()).all()
donation_list = []
for don in donations:
donation_list.append({
"id": str(don.id),
"type": "donation",
"description": "Donation",
"amount_cents": don.amount_cents,
"status": don.status.value if don.status else "unknown",
"payment_method": don.payment_method,
"card_brand": don.card_brand,
"card_last4": don.card_last4,
"stripe_receipt_url": don.stripe_receipt_url,
"created_at": don.created_at.isoformat() if don.created_at else None,
"payment_completed_at": don.payment_completed_at.isoformat() if don.payment_completed_at else None,
"notes": don.notes
})
return {
"subscriptions": subscription_list,
"donations": donation_list,
"total_subscription_amount_cents": sum(s["amount_cents"] or 0 for s in subscription_list),
"total_donation_amount_cents": sum(d["amount_cents"] or 0 for d in donation_list)
}
# ============================================================================
# Calendar Export Endpoints (Universal iCalendar .ics format)
# ============================================================================
@@ -2369,143 +2274,10 @@ async def get_user_by_id(
"email_verified": user.email_verified,
"newsletter_subscribed": user.newsletter_subscribed,
"lead_sources": user.lead_sources,
"member_since": user.member_since.isoformat() if user.member_since else None,
"created_at": user.created_at.isoformat() if user.created_at else None,
"updated_at": user.updated_at.isoformat() if user.updated_at else None
}
@api_router.get("/admin/users/{user_id}/transactions")
async def get_user_transactions(
user_id: str,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.view"))
):
"""
Get a specific user's transaction history (admin only).
Returns subscriptions and donations for the specified user.
"""
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get user's subscriptions with plan details
subscriptions = db.query(Subscription).filter(
Subscription.user_id == user_id
).order_by(Subscription.created_at.desc()).all()
subscription_list = []
for sub in subscriptions:
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == sub.plan_id).first()
subscription_list.append({
"id": str(sub.id),
"type": "subscription",
"description": plan.name if plan else "Subscription",
"amount_cents": sub.amount_paid_cents or (sub.base_subscription_cents + sub.donation_cents),
"base_amount_cents": sub.base_subscription_cents,
"donation_cents": sub.donation_cents,
"status": sub.status.value if sub.status else "unknown",
"payment_method": sub.payment_method,
"card_brand": sub.card_brand,
"card_last4": sub.card_last4,
"stripe_receipt_url": sub.stripe_receipt_url,
"created_at": sub.created_at.isoformat() if sub.created_at else None,
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
"start_date": sub.start_date.isoformat() if sub.start_date else None,
"end_date": sub.end_date.isoformat() if sub.end_date else None,
"billing_cycle": plan.billing_cycle if plan else None,
"manual_payment": sub.manual_payment,
"manual_payment_notes": sub.manual_payment_notes
})
# Get user's donations
donations = db.query(Donation).filter(
Donation.user_id == user_id
).order_by(Donation.created_at.desc()).all()
donation_list = []
for don in donations:
donation_list.append({
"id": str(don.id),
"type": "donation",
"description": "Donation",
"amount_cents": don.amount_cents,
"status": don.status.value if don.status else "unknown",
"payment_method": don.payment_method,
"card_brand": don.card_brand,
"card_last4": don.card_last4,
"stripe_receipt_url": don.stripe_receipt_url,
"created_at": don.created_at.isoformat() if don.created_at else None,
"payment_completed_at": don.payment_completed_at.isoformat() if don.payment_completed_at else None,
"notes": don.notes
})
return {
"user_id": str(user.id),
"user_name": f"{user.first_name} {user.last_name}",
"subscriptions": subscription_list,
"donations": donation_list,
"total_subscription_amount_cents": sum(s["amount_cents"] or 0 for s in subscription_list),
"total_donation_amount_cents": sum(d["amount_cents"] or 0 for d in donation_list)
}
@api_router.put("/admin/users/{user_id}")
async def update_user_profile(
user_id: str,
request: AdminUpdateUserRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("users.edit"))
):
"""Update user profile fields (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Update basic personal information
if request.first_name is not None:
user.first_name = request.first_name
if request.last_name is not None:
user.last_name = request.last_name
if request.phone is not None:
user.phone = request.phone
if request.address is not None:
user.address = request.address
if request.city is not None:
user.city = request.city
if request.state is not None:
user.state = request.state
if request.zipcode is not None:
user.zipcode = request.zipcode
if request.date_of_birth is not None:
user.date_of_birth = request.date_of_birth
# Update member_since (admin only)
if request.member_since is not None:
user.member_since = request.member_since
# Update partner information
if request.partner_first_name is not None:
user.partner_first_name = request.partner_first_name
if request.partner_last_name is not None:
user.partner_last_name = request.partner_last_name
if request.partner_is_member is not None:
user.partner_is_member = request.partner_is_member
if request.partner_plan_to_become_member is not None:
user.partner_plan_to_become_member = request.partner_plan_to_become_member
if request.referred_by_member_name is not None:
user.referred_by_member_name = request.referred_by_member_name
user.updated_at = datetime.now(timezone.utc)
db.commit()
db.refresh(user)
logger.info(f"Admin {current_user.email} updated profile for user {user.email}")
return {
"message": "User profile updated successfully",
"user_id": str(user.id)
}
@api_router.put("/admin/users/{user_id}/validate")
async def validate_user(
user_id: str,
@@ -2704,9 +2476,6 @@ async def activate_payment_manually(
# 6. Activate user
user.status = UserStatus.active
set_user_role(user, UserRole.member, db)
# Set member_since only if not already set (first time activation)
if not user.member_since:
user.member_since = datetime.now(timezone.utc)
user.updated_at = datetime.now(timezone.utc)
# 7. Commit
@@ -4756,17 +4525,8 @@ async def get_all_subscriptions(
"donation_cents": sub.donation_cents,
"payment_method": sub.payment_method,
"stripe_subscription_id": sub.stripe_subscription_id,
"stripe_customer_id": sub.stripe_customer_id,
"created_at": sub.created_at,
"updated_at": sub.updated_at,
# Stripe transaction metadata
"stripe_payment_intent_id": sub.stripe_payment_intent_id,
"stripe_charge_id": sub.stripe_charge_id,
"stripe_invoice_id": sub.stripe_invoice_id,
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
"card_last4": sub.card_last4,
"card_brand": sub.card_brand,
"stripe_receipt_url": sub.stripe_receipt_url
"updated_at": sub.updated_at
} for sub in subscriptions]
@api_router.get("/admin/subscriptions/stats")
@@ -5006,15 +4766,7 @@ async def get_donations(
"donor_email": d.donor_email or (d.user.email if d.user else None),
"payment_method": d.payment_method,
"notes": d.notes,
"created_at": d.created_at.isoformat(),
# Stripe transaction metadata
"stripe_payment_intent_id": d.stripe_payment_intent_id,
"stripe_charge_id": d.stripe_charge_id,
"stripe_customer_id": d.stripe_customer_id,
"payment_completed_at": d.payment_completed_at.isoformat() if d.payment_completed_at else None,
"card_last4": d.card_last4,
"card_brand": d.card_brand,
"stripe_receipt_url": d.stripe_receipt_url
"created_at": d.created_at.isoformat()
} for d in donations]
@api_router.get("/admin/donations/stats")
@@ -6328,15 +6080,7 @@ async def create_checkout(
# Create Stripe Checkout Session
import stripe
# Try to get Stripe API key from database first, then fall back to environment
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not stripe_key:
stripe_key = os.getenv("STRIPE_SECRET_KEY")
if not stripe_key:
raise HTTPException(status_code=500, detail="Stripe API key not configured")
stripe.api_key = stripe_key
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
mode = "subscription" if stripe_interval else "payment"
@@ -6411,15 +6155,7 @@ async def create_donation_checkout(
# Create Stripe Checkout Session for one-time payment
import stripe
# Try to get Stripe API key from database first, then fall back to environment
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not stripe_key:
stripe_key = os.getenv("STRIPE_SECRET_KEY")
if not stripe_key:
raise HTTPException(status_code=500, detail="Stripe API key not configured")
stripe.api_key = stripe_key
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
checkout_session = stripe.checkout.Session.create(
payment_method_types=['card'],
@@ -6578,55 +6314,13 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
donation = db.query(Donation).filter(Donation.id == donation_id).first()
if donation:
# Get Stripe API key from database
import stripe
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not stripe_key:
stripe_key = os.getenv("STRIPE_SECRET_KEY")
stripe.api_key = stripe_key
# Extract basic payment info
payment_intent_id = session.get('payment_intent')
donation.status = DonationStatus.completed
donation.stripe_payment_intent_id = payment_intent_id
donation.stripe_customer_id = session.get('customer')
donation.stripe_payment_intent_id = session.get('payment_intent')
donation.payment_method = 'card'
donation.payment_completed_at = datetime.fromtimestamp(session.get('created'), tz=timezone.utc)
# Capture donor email and name from Stripe session if not already set
if not donation.donor_email and session.get('customer_details'):
customer_details = session.get('customer_details')
donation.donor_email = customer_details.get('email')
if not donation.donor_name and customer_details.get('name'):
donation.donor_name = customer_details.get('name')
# Retrieve PaymentIntent to get charge details
try:
if payment_intent_id:
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
# Get charge ID from latest_charge
charge_id = payment_intent.latest_charge if hasattr(payment_intent, 'latest_charge') else None
if charge_id:
# Retrieve the charge to get full details
charge = stripe.Charge.retrieve(charge_id)
donation.stripe_charge_id = charge.id
donation.stripe_receipt_url = charge.receipt_url
# Get card details
if hasattr(charge, 'payment_method_details') and charge.payment_method_details and charge.payment_method_details.card:
card = charge.payment_method_details.card
donation.card_last4 = card.last4
donation.card_brand = card.brand.capitalize() # visa -> Visa
except Exception as e:
logger.error(f"Failed to retrieve Stripe payment details for donation: {str(e)}")
donation.updated_at = datetime.now(timezone.utc)
db.commit()
# Send thank you email only if donor_email exists
if donation.donor_email:
# Send thank you email
try:
from email_service import send_donation_thank_you_email
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
@@ -6637,8 +6331,6 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
)
except Exception as e:
logger.error(f"Failed to send donation thank you email: {str(e)}")
else:
logger.warning(f"Skipping thank you email for donation {donation.id}: no donor email")
logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
else:
@@ -6668,26 +6360,15 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
).first()
if not existing_subscription:
# Get Stripe API key from database
import stripe
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
if not stripe_key:
stripe_key = os.getenv("STRIPE_SECRET_KEY")
stripe.api_key = stripe_key
# Calculate subscription period using custom billing cycle if enabled
from payment_service import calculate_subscription_period
start_date, end_date = calculate_subscription_period(plan)
# Extract basic payment info
payment_intent_id = session.get('payment_intent')
subscription_id = session.get("subscription")
# Create subscription record with donation tracking
subscription = Subscription(
user_id=user.id,
plan_id=plan.id,
stripe_subscription_id=subscription_id,
stripe_subscription_id=session.get("subscription"),
stripe_customer_id=session.get("customer"),
status=SubscriptionStatus.active,
start_date=start_date,
@@ -6695,48 +6376,13 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
amount_paid_cents=total_amount,
base_subscription_cents=base_amount or plan.minimum_price_cents,
donation_cents=donation_amount,
payment_method="stripe",
stripe_payment_intent_id=payment_intent_id,
payment_completed_at=datetime.fromtimestamp(session.get('created'), tz=timezone.utc)
payment_method="stripe"
)
# Retrieve PaymentIntent and Subscription to get detailed transaction info
try:
if payment_intent_id:
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
# Get charge ID from latest_charge
charge_id = payment_intent.latest_charge if hasattr(payment_intent, 'latest_charge') else None
if charge_id:
# Retrieve the charge to get full details
charge = stripe.Charge.retrieve(charge_id)
subscription.stripe_charge_id = charge.id
subscription.stripe_receipt_url = charge.receipt_url
# Get card details
if hasattr(charge, 'payment_method_details') and charge.payment_method_details and charge.payment_method_details.card:
card = charge.payment_method_details.card
subscription.card_last4 = card.last4
subscription.card_brand = card.brand.capitalize() # visa -> Visa
# Get invoice ID from subscription
if subscription_id:
stripe_subscription = stripe.Subscription.retrieve(subscription_id)
if hasattr(stripe_subscription, 'latest_invoice') and stripe_subscription.latest_invoice:
subscription.stripe_invoice_id = stripe_subscription.latest_invoice
except Exception as e:
logger.error(f"Failed to retrieve Stripe payment details for subscription: {str(e)}")
db.add(subscription)
# Update user status and role
user.status = UserStatus.active
set_user_role(user, UserRole.member, db)
# Set member_since only if not already set (first time activation)
if not user.member_since:
user.member_since = datetime.now(timezone.utc)
user.updated_at = datetime.now(timezone.utc)
db.commit()

10
start.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Exit immediately if a command fails
set -e
# Activate virtual environment
source .venv/bin/activate
# Start the backend
python -m uvicorn server:app --reload --port 8000