Merge to LOAF-PROD for Demo #27
Binary file not shown.
Binary file not shown.
86
alembic/versions/add_payment_methods.py
Normal file
86
alembic/versions/add_payment_methods.py
Normal 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)
|
||||||
56
models.py
56
models.py
@@ -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"
|
||||||
|
|||||||
@@ -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
689
server.py
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user