diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index 0234c73..8680241 100644 Binary files a/__pycache__/database.cpython-312.pyc and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 173f7c1..5c0f75b 100644 Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ diff --git a/__pycache__/payment_service.cpython-312.pyc b/__pycache__/payment_service.cpython-312.pyc index 01a6878..f35ddff 100644 Binary files a/__pycache__/payment_service.cpython-312.pyc and b/__pycache__/payment_service.cpython-312.pyc differ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 250dae2..e42c0a0 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/alembic/versions/956ea1628264_add_stripe_transaction_metadata.py b/alembic/versions/956ea1628264_add_stripe_transaction_metadata.py new file mode 100644 index 0000000..fe28920 --- /dev/null +++ b/alembic/versions/956ea1628264_add_stripe_transaction_metadata.py @@ -0,0 +1,76 @@ +"""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') diff --git a/models.py b/models.py index c0b50e0..930ce27 100644 --- a/models.py +++ b/models.py @@ -238,6 +238,15 @@ 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 @@ -269,9 +278,17 @@ class Donation(Base): # Payment details stripe_checkout_session_id = Column(String, nullable=True) - stripe_payment_intent_id = Column(String, nullable=True) + stripe_payment_intent_id = Column(String, nullable=True, index=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)) diff --git a/server.py b/server.py index cbbbc48..8cf51a7 100644 --- a/server.py +++ b/server.py @@ -227,6 +227,7 @@ 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) @@ -2476,6 +2477,9 @@ 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 @@ -4525,8 +4529,17 @@ 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 + "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 } for sub in subscriptions] @api_router.get("/admin/subscriptions/stats") @@ -4766,7 +4779,15 @@ 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() + "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 } for d in donations] @api_router.get("/admin/donations/stats") @@ -6314,23 +6335,67 @@ 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 = session.get('payment_intent') + donation.stripe_payment_intent_id = payment_intent_id + donation.stripe_customer_id = session.get('customer') 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 - try: - from email_service import send_donation_thank_you_email - donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend" - await send_donation_thank_you_email( - donation.donor_email, - donor_first_name, - donation.amount_cents - ) - except Exception as e: - logger.error(f"Failed to send donation thank you email: {str(e)}") + # Send thank you email only if donor_email exists + if donation.donor_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" + await send_donation_thank_you_email( + donation.donor_email, + donor_first_name, + donation.amount_cents + ) + 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: @@ -6360,15 +6425,26 @@ 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=session.get("subscription"), + stripe_subscription_id=subscription_id, stripe_customer_id=session.get("customer"), status=SubscriptionStatus.active, start_date=start_date, @@ -6376,13 +6452,48 @@ 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" + payment_method="stripe", + stripe_payment_intent_id=payment_intent_id, + payment_completed_at=datetime.fromtimestamp(session.get('created'), tz=timezone.utc) ) + + # 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()