Compare commits
27 Commits
docker
...
7d61eddcef
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d61eddcef | |||
|
|
b29bb641f5 | ||
|
|
d322d1334f | ||
|
|
ece1e62913 | ||
|
|
d3a0cabede | ||
| a5fc42b353 | |||
| 37b1ab75df | |||
| f915976cb3 | |||
| 9c5aafc57b | |||
| 3755a71ed8 | |||
| b2293a5588 | |||
| 9f29bf05d8 | |||
| b44d55919e | |||
| 1a6341a94c | |||
| 727cbf4b5c | |||
| 9c3f3c88b8 | |||
| 849a6a32af | |||
| 69b8185414 | |||
| f5f8ca8dc6 | |||
| 661a4cbb7c | |||
| a01a8b9915 | |||
| e126cb988c | |||
| fd988241a1 | |||
| c28eddca67 | |||
| e20542ccdc | |||
| b3f1f5f789 | |||
| 1da045f73f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,3 @@
|
|||||||
.env
|
|
||||||
.venv
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Python Backend .gitignore
|
# Python Backend .gitignore
|
||||||
# For FastAPI + PostgreSQL + Cloudflare R2 + Stripe
|
# For FastAPI + PostgreSQL + Cloudflare R2 + Stripe
|
||||||
@@ -10,7 +8,6 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.envrc
|
.envrc
|
||||||
.sh
|
|
||||||
|
|
||||||
# ===== Python =====
|
# ===== Python =====
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -1,20 +0,0 @@
|
|||||||
# 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.
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')
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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
|
|
||||||
|
|
||||||
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))
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ motor==3.3.1
|
|||||||
msal==1.27.0
|
msal==1.27.0
|
||||||
mypy==1.18.2
|
mypy==1.18.2
|
||||||
mypy_extensions==1.1.0
|
mypy_extensions==1.1.0
|
||||||
numpy==2.2.6
|
numpy==2.3.5
|
||||||
oauthlib==3.3.1
|
oauthlib==3.3.1
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
pandas==2.3.3
|
pandas==2.3.3
|
||||||
|
|||||||
370
server.py
370
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)
|
||||||
@@ -482,6 +483,31 @@ class InviteUserRequest(BaseModel):
|
|||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
phone: 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):
|
class InvitationResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
@@ -1716,6 +1742,75 @@ async def get_my_event_activity(
|
|||||||
"total_rsvps": len(rsvps)
|
"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)
|
# Calendar Export Endpoints (Universal iCalendar .ics format)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -2274,10 +2369,143 @@ async def get_user_by_id(
|
|||||||
"email_verified": user.email_verified,
|
"email_verified": user.email_verified,
|
||||||
"newsletter_subscribed": user.newsletter_subscribed,
|
"newsletter_subscribed": user.newsletter_subscribed,
|
||||||
"lead_sources": user.lead_sources,
|
"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,
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
"updated_at": user.updated_at.isoformat() if user.updated_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")
|
@api_router.put("/admin/users/{user_id}/validate")
|
||||||
async def validate_user(
|
async def validate_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -2476,6 +2704,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 +4756,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 +5006,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")
|
||||||
@@ -6080,7 +6328,15 @@ async def create_checkout(
|
|||||||
|
|
||||||
# Create Stripe Checkout Session
|
# Create Stripe Checkout Session
|
||||||
import stripe
|
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"
|
mode = "subscription" if stripe_interval else "payment"
|
||||||
|
|
||||||
@@ -6155,7 +6411,15 @@ async def create_donation_checkout(
|
|||||||
|
|
||||||
# Create Stripe Checkout Session for one-time payment
|
# Create Stripe Checkout Session for one-time payment
|
||||||
import stripe
|
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(
|
checkout_session = stripe.checkout.Session.create(
|
||||||
payment_method_types=['card'],
|
payment_method_types=['card'],
|
||||||
@@ -6314,13 +6578,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 +6637,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 +6668,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 +6695,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