Merge to LOAF-PROD for Demo #27

Merged
andika merged 10 commits from dev into loaf-prod 2026-02-02 11:11:36 +00:00
6 changed files with 896 additions and 1 deletions
Showing only changes of commit 9754f2db6e - Show all commits

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,86 @@
"""add_payment_methods
Revision ID: a1b2c3d4e5f6
Revises: 956ea1628264
Create Date: 2026-01-30 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'a1b2c3d4e5f6'
down_revision: Union[str, None] = '956ea1628264'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create PaymentMethodType enum
paymentmethodtype = postgresql.ENUM(
'card', 'cash', 'bank_transfer', 'check',
name='paymentmethodtype',
create_type=False
)
paymentmethodtype.create(op.get_bind(), checkfirst=True)
# Add stripe_customer_id to users table
op.add_column('users', sa.Column(
'stripe_customer_id',
sa.String(),
nullable=True,
comment='Stripe Customer ID for payment method management'
))
op.create_index('ix_users_stripe_customer_id', 'users', ['stripe_customer_id'])
# Create payment_methods table
op.create_table(
'payment_methods',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('user_id', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('stripe_payment_method_id', sa.String(), nullable=True, unique=True, comment='Stripe pm_xxx reference'),
sa.Column('card_brand', sa.String(20), nullable=True, comment='Card brand: visa, mastercard, amex, etc.'),
sa.Column('card_last4', sa.String(4), nullable=True, comment='Last 4 digits of card'),
sa.Column('card_exp_month', sa.Integer(), nullable=True, comment='Card expiration month'),
sa.Column('card_exp_year', sa.Integer(), nullable=True, comment='Card expiration year'),
sa.Column('card_funding', sa.String(20), nullable=True, comment='Card funding type: credit, debit, prepaid'),
sa.Column('payment_type', paymentmethodtype, nullable=False, server_default='card'),
sa.Column('is_default', sa.Boolean(), nullable=False, server_default='false', comment='Whether this is the default payment method for auto-renewals'),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true', comment='Soft delete flag - False means removed'),
sa.Column('is_manual', sa.Boolean(), nullable=False, server_default='false', comment='True for manually recorded methods (cash/check)'),
sa.Column('manual_notes', sa.Text(), nullable=True, comment='Admin notes for manual payment methods'),
sa.Column('created_by', postgresql.UUID(as_uuid=True), sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment='Admin who added this on behalf of user'),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
)
# Create indexes
op.create_index('ix_payment_methods_user_id', 'payment_methods', ['user_id'])
op.create_index('ix_payment_methods_stripe_pm_id', 'payment_methods', ['stripe_payment_method_id'])
op.create_index('idx_payment_method_user_default', 'payment_methods', ['user_id', 'is_default'])
op.create_index('idx_payment_method_active', 'payment_methods', ['user_id', 'is_active'])
def downgrade() -> None:
# Drop indexes
op.drop_index('idx_payment_method_active', table_name='payment_methods')
op.drop_index('idx_payment_method_user_default', table_name='payment_methods')
op.drop_index('ix_payment_methods_stripe_pm_id', table_name='payment_methods')
op.drop_index('ix_payment_methods_user_id', table_name='payment_methods')
# Drop payment_methods table
op.drop_table('payment_methods')
# Drop stripe_customer_id from users
op.drop_index('ix_users_stripe_customer_id', table_name='users')
op.drop_column('users', 'stripe_customer_id')
# Drop PaymentMethodType enum
paymentmethodtype = postgresql.ENUM(
'card', 'cash', 'bank_transfer', 'check',
name='paymentmethodtype'
)
paymentmethodtype.drop(op.get_bind(), checkfirst=True)

View File

@@ -44,6 +44,13 @@ class DonationStatus(enum.Enum):
completed = "completed" completed = "completed"
failed = "failed" failed = "failed"
class PaymentMethodType(enum.Enum):
card = "card"
cash = "cash"
bank_transfer = "bank_transfer"
check = "check"
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -141,6 +148,9 @@ class User(Base):
role_changed_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when role was last changed") role_changed_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when role was last changed")
role_changed_by = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment="Admin who changed the role") role_changed_by = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='SET NULL'), nullable=True, comment="Admin who changed the role")
# Stripe Customer ID - Centralized for payment method management
stripe_customer_id = Column(String, nullable=True, index=True, comment="Stripe Customer ID for payment method management")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -150,6 +160,52 @@ class User(Base):
rsvps = relationship("EventRSVP", back_populates="user") rsvps = relationship("EventRSVP", back_populates="user")
subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id") subscriptions = relationship("Subscription", back_populates="user", foreign_keys="Subscription.user_id")
role_changer = relationship("User", foreign_keys=[role_changed_by], remote_side="User.id", post_update=True) role_changer = relationship("User", foreign_keys=[role_changed_by], remote_side="User.id", post_update=True)
payment_methods = relationship("PaymentMethod", back_populates="user", foreign_keys="PaymentMethod.user_id")
class PaymentMethod(Base):
"""Stored payment methods for users (Stripe or manual records)"""
__tablename__ = "payment_methods"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
# Stripe payment method reference
stripe_payment_method_id = Column(String, nullable=True, unique=True, index=True, comment="Stripe pm_xxx reference")
# Card details (stored for display purposes - PCI compliant)
card_brand = Column(String(20), nullable=True, comment="Card brand: visa, mastercard, amex, etc.")
card_last4 = Column(String(4), nullable=True, comment="Last 4 digits of card")
card_exp_month = Column(Integer, nullable=True, comment="Card expiration month")
card_exp_year = Column(Integer, nullable=True, comment="Card expiration year")
card_funding = Column(String(20), nullable=True, comment="Card funding type: credit, debit, prepaid")
# Payment type classification
payment_type = Column(SQLEnum(PaymentMethodType), default=PaymentMethodType.card, nullable=False)
# Status flags
is_default = Column(Boolean, default=False, nullable=False, comment="Whether this is the default payment method for auto-renewals")
is_active = Column(Boolean, default=True, nullable=False, comment="Soft delete flag - False means removed")
is_manual = Column(Boolean, default=False, nullable=False, comment="True for manually recorded methods (cash/check)")
# Manual payment notes (for cash/check records)
manual_notes = Column(Text, nullable=True, comment="Admin notes for manual payment methods")
# Audit trail
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True, comment="Admin who added this on behalf of user")
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), nullable=False)
updated_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False)
# Relationships
user = relationship("User", back_populates="payment_methods", foreign_keys=[user_id])
creator = relationship("User", foreign_keys=[created_by])
# Composite index for efficient queries
__table_args__ = (
Index('idx_payment_method_user_default', 'user_id', 'is_default'),
Index('idx_payment_method_active', 'user_id', 'is_active'),
)
class Event(Base): class Event(Base):
__tablename__ = "events" __tablename__ = "events"

View File

@@ -327,6 +327,38 @@ PERMISSIONS = [
"module": "gallery" "module": "gallery"
}, },
# ========== PAYMENT METHODS MODULE ==========
{
"code": "payment_methods.view",
"name": "View Payment Methods",
"description": "View user payment methods (masked)",
"module": "payment_methods"
},
{
"code": "payment_methods.view_sensitive",
"name": "View Sensitive Payment Details",
"description": "View full payment method details including Stripe IDs (requires password)",
"module": "payment_methods"
},
{
"code": "payment_methods.create",
"name": "Create Payment Methods",
"description": "Add payment methods on behalf of users",
"module": "payment_methods"
},
{
"code": "payment_methods.delete",
"name": "Delete Payment Methods",
"description": "Delete user payment methods",
"module": "payment_methods"
},
{
"code": "payment_methods.set_default",
"name": "Set Default Payment Method",
"description": "Set a user's default payment method",
"module": "payment_methods"
},
# ========== SETTINGS MODULE ========== # ========== SETTINGS MODULE ==========
{ {
"code": "settings.view", "code": "settings.view",
@@ -453,6 +485,10 @@ DEFAULT_ROLE_PERMISSIONS = {
"gallery.edit", "gallery.edit",
"gallery.delete", "gallery.delete",
"gallery.moderate", "gallery.moderate",
"payment_methods.view",
"payment_methods.create",
"payment_methods.delete",
"payment_methods.set_default",
"settings.view", "settings.view",
"settings.edit", "settings.edit",
"settings.email_templates", "settings.email_templates",
@@ -460,6 +496,36 @@ DEFAULT_ROLE_PERMISSIONS = {
"settings.logs", "settings.logs",
], ],
UserRole.finance: [
# Finance role has all admin permissions plus sensitive payment access
"users.view",
"users.export",
"events.view",
"events.rsvps",
"events.calendar_export",
"subscriptions.view",
"subscriptions.create",
"subscriptions.edit",
"subscriptions.cancel",
"subscriptions.activate",
"subscriptions.plans",
"financials.view",
"financials.create",
"financials.edit",
"financials.delete",
"financials.export",
"financials.payments",
"newsletters.view",
"bylaws.view",
"gallery.view",
"payment_methods.view",
"payment_methods.view_sensitive", # Finance can view sensitive payment details
"payment_methods.create",
"payment_methods.delete",
"payment_methods.set_default",
"settings.view",
],
# Superadmin gets all permissions automatically in code, # Superadmin gets all permissions automatically in code,
# so we don't need to explicitly assign them # so we don't need to explicitly assign them
UserRole.superadmin: [] UserRole.superadmin: []

689
server.py
View File

@@ -17,7 +17,7 @@ import csv
import io import io
from database import engine, get_db, Base from database import engine, get_db, Base
from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus, SystemSettings from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, ImportRollbackAudit, Donation, DonationType, DonationStatus, SystemSettings, PaymentMethod, PaymentMethodType
from auth import ( from auth import (
get_password_hash, get_password_hash,
verify_password, verify_password,
@@ -6448,6 +6448,693 @@ async def create_donation_checkout(
logger.error(f"Error creating donation checkout: {str(e)}") logger.error(f"Error creating donation checkout: {str(e)}")
raise HTTPException(status_code=500, detail="Failed to create donation checkout") raise HTTPException(status_code=500, detail="Failed to create donation checkout")
# ============================================================
# Payment Method Management API Endpoints
# ============================================================
class PaymentMethodResponse(BaseModel):
id: str
card_brand: Optional[str] = None
card_last4: Optional[str] = None
card_exp_month: Optional[int] = None
card_exp_year: Optional[int] = None
card_funding: Optional[str] = None
payment_type: str
is_default: bool
is_manual: bool
manual_notes: Optional[str] = None
created_at: datetime
model_config = {"from_attributes": True}
class PaymentMethodSaveRequest(BaseModel):
stripe_payment_method_id: str
set_as_default: bool = False
class AdminManualPaymentMethodRequest(BaseModel):
payment_type: Literal["cash", "bank_transfer", "check"]
manual_notes: Optional[str] = None
set_as_default: bool = False
class AdminRevealRequest(BaseModel):
password: str
def get_or_create_stripe_customer(user: User, db: Session) -> str:
"""Get existing or create new Stripe customer for user."""
import stripe
# Get Stripe API key
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
if user.stripe_customer_id:
# Verify customer still exists in Stripe
try:
customer = stripe.Customer.retrieve(user.stripe_customer_id)
# Check if customer was deleted using getattr (Stripe SDK doesn't expose 'deleted' directly)
if getattr(customer, 'deleted', False) or customer.get('deleted', False):
# Customer was deleted, create a new one
user.stripe_customer_id = None
else:
return user.stripe_customer_id
except stripe.error.InvalidRequestError:
# Customer doesn't exist, create a new one
user.stripe_customer_id = None
except Exception as e:
logger.warning(f"Error retrieving Stripe customer {user.stripe_customer_id}: {str(e)}")
user.stripe_customer_id = None
# Create new Stripe customer
customer = stripe.Customer.create(
email=user.email,
name=f"{user.first_name} {user.last_name}",
metadata={"user_id": str(user.id)}
)
user.stripe_customer_id = customer.id
db.commit()
logger.info(f"Created Stripe customer {customer.id} for user {user.id}")
return customer.id
@api_router.get("/payment-methods")
async def list_payment_methods(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""List current user's saved payment methods."""
methods = db.query(PaymentMethod).filter(
PaymentMethod.user_id == current_user.id,
PaymentMethod.is_active == True
).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all()
return [{
"id": str(m.id),
"card_brand": m.card_brand,
"card_last4": m.card_last4,
"card_exp_month": m.card_exp_month,
"card_exp_year": m.card_exp_year,
"card_funding": m.card_funding,
"payment_type": m.payment_type.value,
"is_default": m.is_default,
"is_manual": m.is_manual,
"manual_notes": m.manual_notes if m.is_manual else None,
"created_at": m.created_at.isoformat()
} for m in methods]
@api_router.post("/payment-methods/setup-intent")
async def create_setup_intent(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a Stripe SetupIntent for adding a new payment method."""
import stripe
# Get or create Stripe customer
customer_id = get_or_create_stripe_customer(current_user, db)
# Get Stripe API key
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
# Create SetupIntent
setup_intent = stripe.SetupIntent.create(
customer=customer_id,
payment_method_types=["card"],
metadata={"user_id": str(current_user.id)}
)
logger.info(f"Created SetupIntent for user {current_user.id}")
return {
"client_secret": setup_intent.client_secret,
"setup_intent_id": setup_intent.id
}
@api_router.post("/payment-methods")
async def save_payment_method(
request: PaymentMethodSaveRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Save a payment method after successful SetupIntent confirmation."""
import stripe
# Get Stripe API key
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
# Refresh user from DB to get latest stripe_customer_id
db.refresh(current_user)
# Retrieve payment method from Stripe
try:
pm = stripe.PaymentMethod.retrieve(request.stripe_payment_method_id)
except stripe.error.InvalidRequestError as e:
logger.error(f"Invalid payment method ID: {request.stripe_payment_method_id}, error: {str(e)}")
raise HTTPException(status_code=400, detail="Invalid payment method ID")
# Verify ownership - payment method must be attached to user's customer
pm_customer = pm.customer if hasattr(pm, 'customer') else None
logger.info(f"Verifying PM ownership: pm.customer={pm_customer}, user.stripe_customer_id={current_user.stripe_customer_id}")
if not current_user.stripe_customer_id:
raise HTTPException(status_code=403, detail="User does not have a Stripe customer ID")
if not pm_customer:
raise HTTPException(status_code=403, detail="Payment method is not attached to any customer")
if pm_customer != current_user.stripe_customer_id:
raise HTTPException(status_code=403, detail="Payment method not owned by user")
# Check for duplicate
existing = db.query(PaymentMethod).filter(
PaymentMethod.stripe_payment_method_id == request.stripe_payment_method_id,
PaymentMethod.is_active == True
).first()
if existing:
raise HTTPException(status_code=400, detail="Payment method already saved")
# Handle default setting - unset others if setting this as default
if request.set_as_default:
db.query(PaymentMethod).filter(
PaymentMethod.user_id == current_user.id,
PaymentMethod.is_active == True
).update({"is_default": False})
# Extract card details
card = pm.card if pm.type == "card" else None
# Create payment method record
payment_method = PaymentMethod(
user_id=current_user.id,
stripe_payment_method_id=request.stripe_payment_method_id,
card_brand=card.brand if card else None,
card_last4=card.last4 if card else None,
card_exp_month=card.exp_month if card else None,
card_exp_year=card.exp_year if card else None,
card_funding=card.funding if card else None,
payment_type=PaymentMethodType.card,
is_default=request.set_as_default,
is_active=True,
is_manual=False
)
db.add(payment_method)
db.commit()
db.refresh(payment_method)
logger.info(f"Saved payment method {payment_method.id} for user {current_user.id}")
return {
"id": str(payment_method.id),
"card_brand": payment_method.card_brand,
"card_last4": payment_method.card_last4,
"card_exp_month": payment_method.card_exp_month,
"card_exp_year": payment_method.card_exp_year,
"is_default": payment_method.is_default,
"message": "Payment method saved successfully"
}
@api_router.put("/payment-methods/{payment_method_id}/default")
async def set_default_payment_method(
payment_method_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Set a payment method as the default for auto-renewals."""
try:
pm_uuid = uuid.UUID(payment_method_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid payment method ID")
payment_method = db.query(PaymentMethod).filter(
PaymentMethod.id == pm_uuid,
PaymentMethod.user_id == current_user.id,
PaymentMethod.is_active == True
).first()
if not payment_method:
raise HTTPException(status_code=404, detail="Payment method not found")
# Unset all other defaults
db.query(PaymentMethod).filter(
PaymentMethod.user_id == current_user.id,
PaymentMethod.is_active == True
).update({"is_default": False})
# Set this one as default
payment_method.is_default = True
db.commit()
logger.info(f"Set default payment method {payment_method_id} for user {current_user.id}")
return {"message": "Default payment method updated", "id": str(payment_method.id)}
@api_router.delete("/payment-methods/{payment_method_id}")
async def delete_payment_method(
payment_method_id: str,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete (soft-delete) a saved payment method."""
import stripe
try:
pm_uuid = uuid.UUID(payment_method_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid payment method ID")
payment_method = db.query(PaymentMethod).filter(
PaymentMethod.id == pm_uuid,
PaymentMethod.user_id == current_user.id,
PaymentMethod.is_active == True
).first()
if not payment_method:
raise HTTPException(status_code=404, detail="Payment method not found")
# Detach from Stripe if it's a Stripe payment method
if payment_method.stripe_payment_method_id:
try:
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
stripe.PaymentMethod.detach(payment_method.stripe_payment_method_id)
logger.info(f"Detached Stripe payment method {payment_method.stripe_payment_method_id}")
except stripe.error.StripeError as e:
logger.warning(f"Failed to detach Stripe payment method: {str(e)}")
# Soft delete
payment_method.is_active = False
payment_method.is_default = False
db.commit()
logger.info(f"Deleted payment method {payment_method_id} for user {current_user.id}")
return {"message": "Payment method deleted"}
# ============================================================
# Admin Payment Method Management Endpoints
# ============================================================
@api_router.get("/admin/users/{user_id}/payment-methods")
async def admin_list_user_payment_methods(
user_id: str,
current_user: User = Depends(require_permission("payment_methods.view")),
db: Session = Depends(get_db)
):
"""Admin: List a user's payment methods (masked)."""
try:
user_uuid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID")
user = db.query(User).filter(User.id == user_uuid).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
methods = db.query(PaymentMethod).filter(
PaymentMethod.user_id == user_uuid,
PaymentMethod.is_active == True
).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all()
return [{
"id": str(m.id),
"card_brand": m.card_brand,
"card_last4": m.card_last4,
"card_exp_month": m.card_exp_month,
"card_exp_year": m.card_exp_year,
"card_funding": m.card_funding,
"payment_type": m.payment_type.value,
"is_default": m.is_default,
"is_manual": m.is_manual,
"manual_notes": m.manual_notes if m.is_manual else None,
"created_at": m.created_at.isoformat(),
# Sensitive data masked
"stripe_payment_method_id": None
} for m in methods]
@api_router.post("/admin/users/{user_id}/payment-methods/reveal")
async def admin_reveal_payment_details(
user_id: str,
request: AdminRevealRequest,
current_user: User = Depends(require_permission("payment_methods.view_sensitive")),
db: Session = Depends(get_db)
):
"""Admin: Reveal full payment method details (requires password confirmation)."""
try:
user_uuid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID")
# Verify admin's password
if not verify_password(request.password, current_user.password_hash):
logger.warning(f"Admin {current_user.email} failed password verification for payment reveal")
raise HTTPException(status_code=401, detail="Invalid password")
user = db.query(User).filter(User.id == user_uuid).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
methods = db.query(PaymentMethod).filter(
PaymentMethod.user_id == user_uuid,
PaymentMethod.is_active == True
).order_by(PaymentMethod.is_default.desc(), PaymentMethod.created_at.desc()).all()
# Log sensitive access
logger.info(f"Admin {current_user.email} revealed payment details for user {user_id}")
return [{
"id": str(m.id),
"card_brand": m.card_brand,
"card_last4": m.card_last4,
"card_exp_month": m.card_exp_month,
"card_exp_year": m.card_exp_year,
"card_funding": m.card_funding,
"payment_type": m.payment_type.value,
"is_default": m.is_default,
"is_manual": m.is_manual,
"manual_notes": m.manual_notes,
"created_at": m.created_at.isoformat(),
"stripe_payment_method_id": m.stripe_payment_method_id
} for m in methods]
@api_router.post("/admin/users/{user_id}/payment-methods/setup-intent")
async def admin_create_setup_intent_for_user(
user_id: str,
current_user: User = Depends(require_permission("payment_methods.create")),
db: Session = Depends(get_db)
):
"""Admin: Create a SetupIntent for adding a card on behalf of a user."""
import stripe
try:
user_uuid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID")
user = db.query(User).filter(User.id == user_uuid).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Get or create Stripe customer for the target user
customer_id = get_or_create_stripe_customer(user, db)
# Get Stripe API key
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
# Create SetupIntent
setup_intent = stripe.SetupIntent.create(
customer=customer_id,
payment_method_types=["card"],
metadata={
"user_id": str(user.id),
"created_by_admin": str(current_user.id)
}
)
logger.info(f"Admin {current_user.email} created SetupIntent for user {user_id}")
return {
"client_secret": setup_intent.client_secret,
"setup_intent_id": setup_intent.id
}
@api_router.post("/admin/users/{user_id}/payment-methods")
async def admin_save_payment_method_for_user(
user_id: str,
request: PaymentMethodSaveRequest,
current_user: User = Depends(require_permission("payment_methods.create")),
db: Session = Depends(get_db)
):
"""Admin: Save a payment method on behalf of a user."""
import stripe
try:
user_uuid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID")
user = db.query(User).filter(User.id == user_uuid).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Refresh user to get latest data
db.refresh(user)
# Get Stripe API key
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
# Retrieve payment method from Stripe
try:
pm = stripe.PaymentMethod.retrieve(request.stripe_payment_method_id)
except stripe.error.InvalidRequestError as e:
logger.error(f"Invalid payment method ID: {request.stripe_payment_method_id}, error: {str(e)}")
raise HTTPException(status_code=400, detail="Invalid payment method ID")
# Verify ownership - payment method must be attached to user's customer
pm_customer = pm.customer if hasattr(pm, 'customer') else None
logger.info(f"Admin verifying PM ownership: pm.customer={pm_customer}, user.stripe_customer_id={user.stripe_customer_id}")
if not user.stripe_customer_id:
raise HTTPException(status_code=403, detail="User does not have a Stripe customer ID")
if not pm_customer:
raise HTTPException(status_code=403, detail="Payment method is not attached to any customer")
if pm_customer != user.stripe_customer_id:
raise HTTPException(status_code=403, detail="Payment method not attached to user's Stripe customer")
# Check for duplicate
existing = db.query(PaymentMethod).filter(
PaymentMethod.stripe_payment_method_id == request.stripe_payment_method_id,
PaymentMethod.is_active == True
).first()
if existing:
raise HTTPException(status_code=400, detail="Payment method already saved")
# Handle default setting
if request.set_as_default:
db.query(PaymentMethod).filter(
PaymentMethod.user_id == user.id,
PaymentMethod.is_active == True
).update({"is_default": False})
# Extract card details
card = pm.card if pm.type == "card" else None
# Create payment method record
payment_method = PaymentMethod(
user_id=user.id,
stripe_payment_method_id=request.stripe_payment_method_id,
card_brand=card.brand if card else None,
card_last4=card.last4 if card else None,
card_exp_month=card.exp_month if card else None,
card_exp_year=card.exp_year if card else None,
card_funding=card.funding if card else None,
payment_type=PaymentMethodType.card,
is_default=request.set_as_default,
is_active=True,
is_manual=False,
created_by=current_user.id
)
db.add(payment_method)
db.commit()
db.refresh(payment_method)
logger.info(f"Admin {current_user.email} saved payment method {payment_method.id} for user {user_id}")
return {
"id": str(payment_method.id),
"card_brand": payment_method.card_brand,
"card_last4": payment_method.card_last4,
"message": "Payment method saved successfully"
}
@api_router.post("/admin/users/{user_id}/payment-methods/manual")
async def admin_record_manual_payment_method(
user_id: str,
request: AdminManualPaymentMethodRequest,
current_user: User = Depends(require_permission("payment_methods.create")),
db: Session = Depends(get_db)
):
"""Admin: Record a manual payment method (cash, check, bank transfer)."""
try:
user_uuid = uuid.UUID(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user ID")
user = db.query(User).filter(User.id == user_uuid).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
# Map payment type string to enum
payment_type_map = {
"cash": PaymentMethodType.cash,
"bank_transfer": PaymentMethodType.bank_transfer,
"check": PaymentMethodType.check
}
payment_type = payment_type_map.get(request.payment_type)
if not payment_type:
raise HTTPException(status_code=400, detail="Invalid payment type")
# Handle default setting
if request.set_as_default:
db.query(PaymentMethod).filter(
PaymentMethod.user_id == user.id,
PaymentMethod.is_active == True
).update({"is_default": False})
# Create manual payment method record
payment_method = PaymentMethod(
user_id=user.id,
stripe_payment_method_id=None,
payment_type=payment_type,
is_default=request.set_as_default,
is_active=True,
is_manual=True,
manual_notes=request.manual_notes,
created_by=current_user.id
)
db.add(payment_method)
db.commit()
db.refresh(payment_method)
logger.info(f"Admin {current_user.email} recorded manual payment method {payment_method.id} ({payment_type.value}) for user {user_id}")
return {
"id": str(payment_method.id),
"payment_type": payment_method.payment_type.value,
"message": "Manual payment method recorded successfully"
}
@api_router.put("/admin/users/{user_id}/payment-methods/{payment_method_id}/default")
async def admin_set_default_payment_method(
user_id: str,
payment_method_id: str,
current_user: User = Depends(require_permission("payment_methods.set_default")),
db: Session = Depends(get_db)
):
"""Admin: Set a user's payment method as default."""
try:
user_uuid = uuid.UUID(user_id)
pm_uuid = uuid.UUID(payment_method_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ID format")
user = db.query(User).filter(User.id == user_uuid).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
payment_method = db.query(PaymentMethod).filter(
PaymentMethod.id == pm_uuid,
PaymentMethod.user_id == user_uuid,
PaymentMethod.is_active == True
).first()
if not payment_method:
raise HTTPException(status_code=404, detail="Payment method not found")
# Unset all other defaults
db.query(PaymentMethod).filter(
PaymentMethod.user_id == user_uuid,
PaymentMethod.is_active == True
).update({"is_default": False})
# Set this one as default
payment_method.is_default = True
db.commit()
logger.info(f"Admin {current_user.email} set default payment method {payment_method_id} for user {user_id}")
return {"message": "Default payment method updated", "id": str(payment_method.id)}
@api_router.delete("/admin/users/{user_id}/payment-methods/{payment_method_id}")
async def admin_delete_payment_method(
user_id: str,
payment_method_id: str,
current_user: User = Depends(require_permission("payment_methods.delete")),
db: Session = Depends(get_db)
):
"""Admin: Delete a user's payment method."""
import stripe
try:
user_uuid = uuid.UUID(user_id)
pm_uuid = uuid.UUID(payment_method_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid ID format")
user = db.query(User).filter(User.id == user_uuid).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
payment_method = db.query(PaymentMethod).filter(
PaymentMethod.id == pm_uuid,
PaymentMethod.user_id == user_uuid,
PaymentMethod.is_active == True
).first()
if not payment_method:
raise HTTPException(status_code=404, detail="Payment method not found")
# Detach from Stripe if it's a Stripe payment method
if payment_method.stripe_payment_method_id:
try:
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
stripe.PaymentMethod.detach(payment_method.stripe_payment_method_id)
logger.info(f"Detached Stripe payment method {payment_method.stripe_payment_method_id}")
except stripe.error.StripeError as e:
logger.warning(f"Failed to detach Stripe payment method: {str(e)}")
# Soft delete
payment_method.is_active = False
payment_method.is_default = False
db.commit()
logger.info(f"Admin {current_user.email} deleted payment method {payment_method_id} for user {user_id}")
return {"message": "Payment method deleted"}
@api_router.post("/contact") @api_router.post("/contact")
async def submit_contact_form( async def submit_contact_form(
request: ContactFormRequest, request: ContactFormRequest,