- Details Column - Expandable chevron button for each row- Expandable Transaction Details - Click chevron to show/hide details- Payment Information Section:- Stripe Transaction IDs Section- Copy to Clipboard - One-click copy for all transaction IDs- Update Stripe webhook event permission on Stripe Config page.
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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')
|
||||||
19
models.py
19
models.py
@@ -238,6 +238,15 @@ class Subscription(Base):
|
|||||||
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
|
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
|
||||||
# Note: amount_paid_cents = base_subscription_cents + donation_cents
|
# 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 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
|
||||||
manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment
|
manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment
|
||||||
@@ -269,9 +278,17 @@ class Donation(Base):
|
|||||||
|
|
||||||
# Payment details
|
# Payment details
|
||||||
stripe_checkout_session_id = Column(String, nullable=True)
|
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.
|
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
|
# Metadata
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
123
server.py
123
server.py
@@ -227,6 +227,7 @@ class UserResponse(BaseModel):
|
|||||||
role: str
|
role: str
|
||||||
email_verified: bool
|
email_verified: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
member_since: Optional[datetime] = None # Date when user became active member
|
||||||
# Profile
|
# Profile
|
||||||
profile_photo_url: Optional[str] = None
|
profile_photo_url: Optional[str] = None
|
||||||
# Subscription info (optional)
|
# Subscription info (optional)
|
||||||
@@ -2476,6 +2477,9 @@ async def activate_payment_manually(
|
|||||||
# 6. Activate user
|
# 6. Activate user
|
||||||
user.status = UserStatus.active
|
user.status = UserStatus.active
|
||||||
set_user_role(user, UserRole.member, db)
|
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)
|
user.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# 7. Commit
|
# 7. Commit
|
||||||
@@ -4525,8 +4529,17 @@ async def get_all_subscriptions(
|
|||||||
"donation_cents": sub.donation_cents,
|
"donation_cents": sub.donation_cents,
|
||||||
"payment_method": sub.payment_method,
|
"payment_method": sub.payment_method,
|
||||||
"stripe_subscription_id": sub.stripe_subscription_id,
|
"stripe_subscription_id": sub.stripe_subscription_id,
|
||||||
|
"stripe_customer_id": sub.stripe_customer_id,
|
||||||
"created_at": sub.created_at,
|
"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]
|
} for sub in subscriptions]
|
||||||
|
|
||||||
@api_router.get("/admin/subscriptions/stats")
|
@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),
|
"donor_email": d.donor_email or (d.user.email if d.user else None),
|
||||||
"payment_method": d.payment_method,
|
"payment_method": d.payment_method,
|
||||||
"notes": d.notes,
|
"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]
|
} for d in donations]
|
||||||
|
|
||||||
@api_router.get("/admin/donations/stats")
|
@api_router.get("/admin/donations/stats")
|
||||||
@@ -6314,13 +6335,55 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
donation = db.query(Donation).filter(Donation.id == donation_id).first()
|
donation = db.query(Donation).filter(Donation.id == donation_id).first()
|
||||||
|
|
||||||
if donation:
|
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.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_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)
|
donation.updated_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Send thank you email
|
# Send thank you email only if donor_email exists
|
||||||
|
if donation.donor_email:
|
||||||
try:
|
try:
|
||||||
from email_service import send_donation_thank_you_email
|
from email_service import send_donation_thank_you_email
|
||||||
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
|
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
|
||||||
@@ -6331,6 +6394,8 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send donation thank you email: {str(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})")
|
logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
|
||||||
else:
|
else:
|
||||||
@@ -6360,15 +6425,26 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not existing_subscription:
|
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
|
# Calculate subscription period using custom billing cycle if enabled
|
||||||
from payment_service import calculate_subscription_period
|
from payment_service import calculate_subscription_period
|
||||||
start_date, end_date = calculate_subscription_period(plan)
|
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
|
# 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=subscription_id,
|
||||||
stripe_customer_id=session.get("customer"),
|
stripe_customer_id=session.get("customer"),
|
||||||
status=SubscriptionStatus.active,
|
status=SubscriptionStatus.active,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -6376,13 +6452,48 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
amount_paid_cents=total_amount,
|
amount_paid_cents=total_amount,
|
||||||
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
||||||
donation_cents=donation_amount,
|
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)
|
db.add(subscription)
|
||||||
|
|
||||||
# Update user status and role
|
# Update user status and role
|
||||||
user.status = UserStatus.active
|
user.status = UserStatus.active
|
||||||
set_user_role(user, UserRole.member, db)
|
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)
|
user.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user