27 Commits

Author SHA1 Message Date
7d61eddcef Merge pull request 'dev' (#25) from dev into loaf-prod
Reviewed-on: #25
2026-01-26 11:20:14 +00:00
Koncept Kit
b29bb641f5 Fixes 2026-01-24 23:56:21 +07:00
Koncept Kit
d322d1334f 1. Added member_since to GET Response- - Endpoint: GET /api/admin/users/{user_id}- Now includes: member_since: 2024-03-15T10:30:00Z (or null)2. Created NEW PUT Endpoint for Admin User Profile Updates- Endpoint: PUT /api/admin/users/{user_id}- Permission Required: users.edit (admins and superadmins have this) 2026-01-21 11:35:19 +07:00
Koncept Kit
ece1e62913 Was reading from .env only → NOW FIXED to read from database 2026-01-21 00:10:02 +07:00
Koncept Kit
d3a0cabede - 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. 2026-01-20 23:51:38 +07:00
a5fc42b353 Merge pull request 'Database prevent dead connection errors and make login work on the first try' (#24) from dev into loaf-prod
Reviewed-on: #24
2026-01-07 09:42:14 +00:00
37b1ab75df Merge pull request 'Merge from Dev to LOAF Production' (#23) from dev into loaf-prod
Reviewed-on: #23
2026-01-07 08:43:14 +00:00
f915976cb3 Merge pull request 'feat: Implement Option 3 - Proper RBAC with role-based staff invitations' (#22) from dev into loaf-prod
Reviewed-on: #22
2026-01-06 08:35:09 +00:00
9c5aafc57b Merge pull request 'Add missing endpoints, fix batch updates, and implement RSVP status' (#21) from dev into loaf-prod
Reviewed-on: #21
2026-01-05 18:08:21 +00:00
3755a71ed8 Merge pull request 'Alembic migration for synchronize Database' (#20) from dev into loaf-prod
Reviewed-on: #20
2026-01-05 14:16:21 +00:00
b2293a5588 Merge pull request 'Alembic fix for PROD' (#19) from dev into loaf-prod
Reviewed-on: #19
2026-01-05 10:31:38 +00:00
9f29bf05d8 Merge pull request 'Database Migration fix' (#18) from dev into loaf-prod
Reviewed-on: #18
2026-01-05 10:26:08 +00:00
b44d55919e Merge pull request 'Alembic Database fix' (#17) from dev into loaf-prod
Reviewed-on: #17
2026-01-05 10:16:04 +00:00
1a6341a94c Merge pull request 'Alembic Database Syncronization' (#16) from dev into loaf-prod
Reviewed-on: #16
2026-01-05 10:09:27 +00:00
727cbf4b5c Merge pull request 'Merge from dev' (#15) from dev into loaf-prod
Reviewed-on: #15
2026-01-05 08:49:16 +00:00
9c3f3c88b8 Merge pull request 'Add comprehensive column check and migration 009' (#14) from dev into loaf-prod
Reviewed-on: #14
2026-01-04 16:19:51 +00:00
849a6a32af Merge pull request 'Add missing donations table columns' (#13) from dev into loaf-prod
Reviewed-on: #13
2026-01-04 16:10:27 +00:00
69b8185414 Merge pull request 'Fix migration 007 - skip existing columns' (#12) from dev into loaf-prod
Reviewed-on: #12
2026-01-04 16:06:27 +00:00
f5f8ca8dc6 Merge pull request 'Add missing subscription_plans columns' (#11) from dev into loaf-prod
Reviewed-on: #11
2026-01-04 16:03:43 +00:00
661a4cbb7c Merge pull request 'Fix subscription_plans.is_active column name' (#10) from dev into loaf-prod
Reviewed-on: #10
2026-01-04 15:58:05 +00:00
a01a8b9915 Merge pull request 'Superadmin nullable fix' (#9) from dev into loaf-prod
Reviewed-on: #9
2026-01-04 15:35:59 +00:00
e126cb988c Merge pull request 'Subscription and Storage data mismatch' (#8) from dev into loaf-prod
Reviewed-on: #8
2026-01-04 15:28:46 +00:00
fd988241a1 Merge pull request 'Subscription and Storage data mismatch' (#7) from dev into loaf-prod
Reviewed-on: #7
2026-01-04 15:24:11 +00:00
c28eddca67 Merge pull request 'Fix database mismatches' (#6) from dev into loaf-prod
Reviewed-on: #6
2026-01-04 15:17:18 +00:00
e20542ccdc Merge pull request 'Fix database mismatches' (#5) from dev into loaf-prod
Reviewed-on: #5
2026-01-04 15:02:09 +00:00
b3f1f5f789 Merge pull request 'Prod Deployment Preparation' (#4) from dev into loaf-prod
Reviewed-on: #4
2026-01-04 12:10:12 +00:00
1da045f73f Merge pull request 'Update Gitignore' (#3) from dev into loaf-prod
Reviewed-on: #3
2026-01-02 08:45:29 +00:00
7 changed files with 466 additions and 19 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -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')

View File

@@ -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))

390
server.py
View File

@@ -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)
@@ -482,6 +483,31 @@ 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
@@ -1716,6 +1742,75 @@ 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)
# ============================================================================
@@ -2274,10 +2369,143 @@ 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,
@@ -2476,6 +2704,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 +4756,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 +5006,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")
@@ -6080,7 +6328,15 @@ async def create_checkout(
# Create Stripe Checkout Session
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
# 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
mode = "subscription" if stripe_interval else "payment"
@@ -6155,7 +6411,15 @@ async def create_donation_checkout(
# Create Stripe Checkout Session for one-time payment
import stripe
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
# 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
checkout_session = stripe.checkout.Session.create(
payment_method_types=['card'],
@@ -6314,23 +6578,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 +6668,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 +6695,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()