diff --git a/.env.example b/.env.example index a20ff27..f2f1d6d 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,10 @@ JWT_SECRET=your-secret-key-change-this-in-production JWT_ALGORITHM=HS256 ACCESS_TOKEN_EXPIRE_MINUTES=30 +# Settings Encryption (for database-stored sensitive settings) +# Generate with: python -c "import secrets; print(secrets.token_urlsafe(64))" +SETTINGS_ENCRYPTION_KEY=your-encryption-key-generate-with-command-above + # SMTP Email Configuration (Port 465 - SSL/TLS) SMTP_HOST=p.konceptkit.com SMTP_PORT=465 @@ -28,7 +32,14 @@ SMTP_FROM_NAME=LOAF Membership # Frontend URL FRONTEND_URL=http://localhost:3000 -# Stripe Configuration (for future payment integration) +# Backend URL (for webhook URLs and API references) +# Used to construct Stripe webhook URL shown in Admin Settings +BACKEND_URL=http://localhost:8000 + +# Stripe Configuration (NOW DATABASE-DRIVEN via Admin Settings page) +# Configure Stripe credentials through the Admin Settings UI (requires SETTINGS_ENCRYPTION_KEY) +# No longer requires .env variables - managed through database for dynamic updates +# Legacy .env variables below are deprecated: # STRIPE_SECRET_KEY=sk_test_... # STRIPE_WEBHOOK_SECRET=whsec_... diff --git a/__pycache__/database.cpython-312.pyc b/__pycache__/database.cpython-312.pyc index 0234c73..8680241 100644 Binary files a/__pycache__/database.cpython-312.pyc and b/__pycache__/database.cpython-312.pyc differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index 173f7c1..5c0f75b 100644 Binary files a/__pycache__/models.cpython-312.pyc and b/__pycache__/models.cpython-312.pyc differ diff --git a/__pycache__/payment_service.cpython-312.pyc b/__pycache__/payment_service.cpython-312.pyc index 01a6878..f35ddff 100644 Binary files a/__pycache__/payment_service.cpython-312.pyc and b/__pycache__/payment_service.cpython-312.pyc differ diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 250dae2..d4011c1 100644 Binary files a/__pycache__/server.cpython-312.pyc and b/__pycache__/server.cpython-312.pyc differ diff --git a/alembic/versions/4fa11836f7fd_add_role_audit_fields.py b/alembic/versions/4fa11836f7fd_add_role_audit_fields.py new file mode 100644 index 0000000..ccda8d9 --- /dev/null +++ b/alembic/versions/4fa11836f7fd_add_role_audit_fields.py @@ -0,0 +1,48 @@ +"""add_role_audit_fields + +Revision ID: 4fa11836f7fd +Revises: 013_sync_permissions +Create Date: 2026-01-16 17:21:40.514605 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = '4fa11836f7fd' +down_revision: Union[str, None] = '013_sync_permissions' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add role audit trail columns + op.add_column('users', sa.Column('role_changed_at', sa.DateTime(timezone=True), nullable=True)) + op.add_column('users', sa.Column('role_changed_by', UUID(as_uuid=True), nullable=True)) + + # Create foreign key constraint to track who changed the role + op.create_foreign_key( + 'fk_users_role_changed_by', + 'users', 'users', + ['role_changed_by'], ['id'], + ondelete='SET NULL' + ) + + # Create index for efficient querying by role change date + op.create_index('idx_users_role_changed_at', 'users', ['role_changed_at']) + + +def downgrade() -> None: + # Drop index first + op.drop_index('idx_users_role_changed_at') + + # Drop foreign key constraint + op.drop_constraint('fk_users_role_changed_by', 'users', type_='foreignkey') + + # Drop columns + op.drop_column('users', 'role_changed_by') + op.drop_column('users', 'role_changed_at') diff --git a/alembic/versions/956ea1628264_add_stripe_transaction_metadata.py b/alembic/versions/956ea1628264_add_stripe_transaction_metadata.py new file mode 100644 index 0000000..fe28920 --- /dev/null +++ b/alembic/versions/956ea1628264_add_stripe_transaction_metadata.py @@ -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') diff --git a/alembic/versions/ec4cb4a49cde_add_system_settings_table.py b/alembic/versions/ec4cb4a49cde_add_system_settings_table.py new file mode 100644 index 0000000..d403c1c --- /dev/null +++ b/alembic/versions/ec4cb4a49cde_add_system_settings_table.py @@ -0,0 +1,68 @@ +"""add_system_settings_table + +Revision ID: ec4cb4a49cde +Revises: 4fa11836f7fd +Create Date: 2026-01-16 18:16:00.283455 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import UUID + + +# revision identifiers, used by Alembic. +revision: str = 'ec4cb4a49cde' +down_revision: Union[str, None] = '4fa11836f7fd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Create enum for setting types (only if not exists) + op.execute(""" + DO $$ BEGIN + CREATE TYPE settingtype AS ENUM ('plaintext', 'encrypted', 'json'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """) + + # Create system_settings table + op.execute(""" + CREATE TABLE system_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + setting_key VARCHAR(100) UNIQUE NOT NULL, + setting_value TEXT, + setting_type settingtype NOT NULL DEFAULT 'plaintext'::settingtype, + description TEXT, + updated_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_sensitive BOOLEAN NOT NULL DEFAULT FALSE + ); + + COMMENT ON COLUMN system_settings.setting_key IS 'Unique setting identifier (e.g., stripe_secret_key)'; + COMMENT ON COLUMN system_settings.setting_value IS 'Setting value (encrypted if setting_type is encrypted)'; + COMMENT ON COLUMN system_settings.setting_type IS 'Type of setting: plaintext, encrypted, or json'; + COMMENT ON COLUMN system_settings.description IS 'Human-readable description of the setting'; + COMMENT ON COLUMN system_settings.updated_by IS 'User who last updated this setting'; + COMMENT ON COLUMN system_settings.is_sensitive IS 'Whether this setting contains sensitive data'; + """) + + # Create indexes + op.create_index('idx_system_settings_key', 'system_settings', ['setting_key']) + op.create_index('idx_system_settings_updated_at', 'system_settings', ['updated_at']) + + +def downgrade() -> None: + # Drop indexes + op.drop_index('idx_system_settings_updated_at') + op.drop_index('idx_system_settings_key') + + # Drop table + op.drop_table('system_settings') + + # Drop enum + op.execute('DROP TYPE IF EXISTS settingtype') diff --git a/encryption_service.py b/encryption_service.py new file mode 100644 index 0000000..f12ee46 --- /dev/null +++ b/encryption_service.py @@ -0,0 +1,122 @@ +""" +Encryption service for sensitive settings stored in database. + +Uses Fernet symmetric encryption (AES-128 in CBC mode with HMAC authentication). +The encryption key is derived from a master secret stored in .env. +""" + +import os +import base64 +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC +from cryptography.hazmat.backends import default_backend + + +class EncryptionService: + """Service for encrypting and decrypting sensitive configuration values""" + + def __init__(self): + # Get master encryption key from environment + # This should be a long, random string (e.g., 64 characters) + # Generate one with: python -c "import secrets; print(secrets.token_urlsafe(64))" + self.master_secret = os.environ.get('SETTINGS_ENCRYPTION_KEY') + + if not self.master_secret: + raise ValueError( + "SETTINGS_ENCRYPTION_KEY environment variable not set. " + "Generate one with: python -c \"import secrets; print(secrets.token_urlsafe(64))\"" + ) + + # Derive encryption key from master secret using PBKDF2HMAC + # This adds an extra layer of security + kdf = PBKDF2HMAC( + algorithm=hashes.SHA256(), + length=32, + salt=b'systemsettings', # Fixed salt (OK for key derivation from strong secret) + iterations=100000, + backend=default_backend() + ) + key = base64.urlsafe_b64encode(kdf.derive(self.master_secret.encode())) + self.cipher = Fernet(key) + + def encrypt(self, plaintext: str) -> str: + """ + Encrypt a plaintext string. + + Args: + plaintext: The string to encrypt + + Returns: + Base64-encoded encrypted string + """ + if not plaintext: + return "" + + encrypted_bytes = self.cipher.encrypt(plaintext.encode()) + return encrypted_bytes.decode('utf-8') + + def decrypt(self, encrypted: str) -> str: + """ + Decrypt an encrypted string. + + Args: + encrypted: The base64-encoded encrypted string + + Returns: + Decrypted plaintext string + + Raises: + cryptography.fernet.InvalidToken: If decryption fails (wrong key or corrupted data) + """ + if not encrypted: + return "" + + decrypted_bytes = self.cipher.decrypt(encrypted.encode()) + return decrypted_bytes.decode('utf-8') + + def is_encrypted(self, value: str) -> bool: + """ + Check if a value appears to be encrypted (starts with Fernet token format). + + This is a heuristic check - not 100% reliable but useful for validation. + + Args: + value: String to check + + Returns: + True if value looks like a Fernet token + """ + if not value: + return False + + # Fernet tokens are base64-encoded and start with version byte (gAAAAA...) + # They're always > 60 characters + try: + return len(value) > 60 and value.startswith('gAAAAA') + except: + return False + + +# Global encryption service instance +# Initialize on module import so it fails fast if encryption key is missing +try: + encryption_service = EncryptionService() +except ValueError as e: + print(f"WARNING: {e}") + print("Encryption service will not be available.") + encryption_service = None + + +def get_encryption_service() -> EncryptionService: + """ + Get the global encryption service instance. + + Raises: + ValueError: If encryption service is not initialized (missing SETTINGS_ENCRYPTION_KEY) + """ + if encryption_service is None: + raise ValueError( + "Encryption service not initialized. Set SETTINGS_ENCRYPTION_KEY environment variable." + ) + return encryption_service diff --git a/models.py b/models.py index a8d8d30..930ce27 100644 --- a/models.py +++ b/models.py @@ -137,6 +137,10 @@ class User(Base): wordpress_user_id = Column(BigInteger, nullable=True, comment="Original WordPress user ID") wordpress_registered_date = Column(DateTime(timezone=True), nullable=True, comment="Original WordPress registration date") + # Role Change Audit Trail + 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") + 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)) @@ -145,6 +149,7 @@ class User(Base): events_created = relationship("Event", back_populates="creator") rsvps = relationship("EventRSVP", back_populates="user") 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) class Event(Base): __tablename__ = "events" @@ -233,6 +238,15 @@ class Subscription(Base): donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount # Note: amount_paid_cents = base_subscription_cents + donation_cents + # Stripe transaction metadata (for validation and audit) + stripe_payment_intent_id = Column(String, nullable=True, index=True) # Initial payment transaction ID + stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference + stripe_invoice_id = Column(String, nullable=True, index=True) # Invoice reference + payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe + card_last4 = Column(String(4), nullable=True) # Last 4 digits of card + card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc. + stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt + # Manual payment fields manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment @@ -264,9 +278,17 @@ class Donation(Base): # Payment details stripe_checkout_session_id = Column(String, nullable=True) - stripe_payment_intent_id = Column(String, nullable=True) + stripe_payment_intent_id = Column(String, nullable=True, index=True) payment_method = Column(String, nullable=True) # card, bank_transfer, etc. + # Stripe transaction metadata (for validation and audit) + stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference + stripe_customer_id = Column(String, nullable=True, index=True) # Customer ID if created + payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe + card_last4 = Column(String(4), nullable=True) # Last 4 digits of card + card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc. + stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt + # Metadata notes = Column(Text, nullable=True) created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) @@ -509,3 +531,36 @@ class ImportRollbackAudit(Base): # Relationships import_job = relationship("ImportJob") admin_user = relationship("User", foreign_keys=[rolled_back_by]) + + +# ============================================================ +# System Settings Models +# ============================================================ + +class SettingType(enum.Enum): + plaintext = "plaintext" + encrypted = "encrypted" + json = "json" + + +class SystemSettings(Base): + """System-wide configuration settings stored in database""" + __tablename__ = "system_settings" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + setting_key = Column(String(100), unique=True, nullable=False, index=True) + setting_value = Column(Text, nullable=True) + setting_type = Column(SQLEnum(SettingType), default=SettingType.plaintext, nullable=False) + description = Column(Text, nullable=True) + updated_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc), nullable=False) + is_sensitive = Column(Boolean, default=False, nullable=False) + + # Relationships + updater = relationship("User", foreign_keys=[updated_by]) + + # Index on updated_at for audit queries + __table_args__ = ( + Index('idx_system_settings_updated_at', 'updated_at'), + ) diff --git a/payment_service.py b/payment_service.py index 562ddd2..ea95351 100644 --- a/payment_service.py +++ b/payment_service.py @@ -11,11 +11,9 @@ from datetime import datetime, timezone, timedelta # Load environment variables load_dotenv() -# Initialize Stripe with secret key -stripe.api_key = os.getenv("STRIPE_SECRET_KEY") - -# Stripe webhook secret for signature verification -STRIPE_WEBHOOK_SECRET = os.getenv("STRIPE_WEBHOOK_SECRET") +# NOTE: Stripe credentials are now database-driven +# These .env fallbacks are kept for backward compatibility only +# The actual credentials are loaded dynamically from system_settings table def create_checkout_session( user_id: str, @@ -23,11 +21,15 @@ def create_checkout_session( plan_id: str, stripe_price_id: str, success_url: str, - cancel_url: str + cancel_url: str, + db = None ): """ Create a Stripe Checkout session for subscription payment. + Args: + db: Database session (optional, for reading Stripe credentials from database) + Args: user_id: User's UUID user_email: User's email address @@ -39,6 +41,28 @@ def create_checkout_session( Returns: dict: Checkout session object with session ID and URL """ + # Load Stripe API key from database if available + if db: + try: + # Import here to avoid circular dependency + from models import SystemSettings, SettingType + from encryption_service import get_encryption_service + + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == 'stripe_secret_key' + ).first() + + if setting and setting.setting_value: + encryption_service = get_encryption_service() + stripe.api_key = encryption_service.decrypt(setting.setting_value) + except Exception as e: + # Fallback to .env if database read fails + print(f"Failed to read Stripe key from database: {e}") + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + else: + # Fallback to .env if no db session + stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + try: # Create Checkout Session checkout_session = stripe.checkout.Session.create( @@ -74,13 +98,14 @@ def create_checkout_session( raise Exception(f"Stripe error: {str(e)}") -def verify_webhook_signature(payload: bytes, sig_header: str) -> dict: +def verify_webhook_signature(payload: bytes, sig_header: str, db=None) -> dict: """ Verify Stripe webhook signature and construct event. Args: payload: Raw webhook payload bytes sig_header: Stripe signature header + db: Database session (optional, for reading webhook secret from database) Returns: dict: Verified webhook event @@ -88,9 +113,32 @@ def verify_webhook_signature(payload: bytes, sig_header: str) -> dict: Raises: ValueError: If signature verification fails """ + # Load webhook secret from database if available + webhook_secret = None + if db: + try: + from models import SystemSettings + from encryption_service import get_encryption_service + + setting = db.query(SystemSettings).filter( + SystemSettings.setting_key == 'stripe_webhook_secret' + ).first() + + if setting and setting.setting_value: + encryption_service = get_encryption_service() + webhook_secret = encryption_service.decrypt(setting.setting_value) + except Exception as e: + print(f"Failed to read webhook secret from database: {e}") + webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET") + else: + webhook_secret = os.getenv("STRIPE_WEBHOOK_SECRET") + + if not webhook_secret: + raise ValueError("STRIPE_WEBHOOK_SECRET not configured") + try: event = stripe.Webhook.construct_event( - payload, sig_header, STRIPE_WEBHOOK_SECRET + payload, sig_header, webhook_secret ) return event except ValueError as e: diff --git a/server.py b/server.py index 298f7fc..00676ec 100644 --- a/server.py +++ b/server.py @@ -227,6 +227,7 @@ class UserResponse(BaseModel): role: str email_verified: bool created_at: datetime + member_since: Optional[datetime] = None # Date when user became active member # Profile profile_photo_url: Optional[str] = None # Subscription info (optional) @@ -482,6 +483,31 @@ class InviteUserRequest(BaseModel): last_name: Optional[str] = None phone: Optional[str] = None +class AdminUpdateUserRequest(BaseModel): + """Admin-only endpoint for updating user profile fields""" + first_name: Optional[str] = None + last_name: Optional[str] = None + phone: Optional[str] = None + address: Optional[str] = None + city: Optional[str] = None + state: Optional[str] = None + zipcode: Optional[str] = None + date_of_birth: Optional[datetime] = None + member_since: Optional[datetime] = None + # Partner information + partner_first_name: Optional[str] = None + partner_last_name: Optional[str] = None + partner_is_member: Optional[bool] = None + partner_plan_to_become_member: Optional[bool] = None + referred_by_member_name: Optional[str] = None + + @validator('date_of_birth', 'member_since', pre=True) + def empty_str_to_none(cls, v): + """Convert empty string to None for optional datetime fields""" + if v == '' or v is None: + return None + return v + class InvitationResponse(BaseModel): id: str email: str @@ -514,6 +540,10 @@ class AcceptInvitationRequest(BaseModel): zipcode: Optional[str] = None date_of_birth: Optional[datetime] = None +class ChangeRoleRequest(BaseModel): + role: str + role_id: Optional[str] = None # For custom roles + # Auth Routes @api_router.post("/auth/register") async def register(request: RegisterRequest, db: Session = Depends(get_db)): @@ -1712,6 +1742,75 @@ async def get_my_event_activity( "total_rsvps": len(rsvps) } +# ============================================================================ +# Member Transaction History Endpoint +# ============================================================================ +@api_router.get("/members/transactions") +async def get_member_transactions( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get current member's transaction history including subscriptions and donations. + Returns both types of transactions sorted by date (newest first). + """ + # Get user's subscriptions with plan details + subscriptions = db.query(Subscription).filter( + Subscription.user_id == current_user.id + ).order_by(Subscription.created_at.desc()).all() + + subscription_list = [] + for sub in subscriptions: + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == sub.plan_id).first() + subscription_list.append({ + "id": str(sub.id), + "type": "subscription", + "description": plan.name if plan else "Subscription", + "amount_cents": sub.amount_paid_cents or (sub.base_subscription_cents + sub.donation_cents), + "base_amount_cents": sub.base_subscription_cents, + "donation_cents": sub.donation_cents, + "status": sub.status.value if sub.status else "unknown", + "payment_method": sub.payment_method, + "card_brand": sub.card_brand, + "card_last4": sub.card_last4, + "stripe_receipt_url": sub.stripe_receipt_url, + "created_at": sub.created_at.isoformat() if sub.created_at else None, + "payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None, + "start_date": sub.start_date.isoformat() if sub.start_date else None, + "end_date": sub.end_date.isoformat() if sub.end_date else None, + "billing_cycle": plan.billing_cycle if plan else None, + "manual_payment": sub.manual_payment + }) + + # Get user's donations + donations = db.query(Donation).filter( + Donation.user_id == current_user.id + ).order_by(Donation.created_at.desc()).all() + + donation_list = [] + for don in donations: + donation_list.append({ + "id": str(don.id), + "type": "donation", + "description": "Donation", + "amount_cents": don.amount_cents, + "status": don.status.value if don.status else "unknown", + "payment_method": don.payment_method, + "card_brand": don.card_brand, + "card_last4": don.card_last4, + "stripe_receipt_url": don.stripe_receipt_url, + "created_at": don.created_at.isoformat() if don.created_at else None, + "payment_completed_at": don.payment_completed_at.isoformat() if don.payment_completed_at else None, + "notes": don.notes + }) + + return { + "subscriptions": subscription_list, + "donations": donation_list, + "total_subscription_amount_cents": sum(s["amount_cents"] or 0 for s in subscription_list), + "total_donation_amount_cents": sum(d["amount_cents"] or 0 for d in donation_list) + } + # ============================================================================ # Calendar Export Endpoints (Universal iCalendar .ics format) # ============================================================================ @@ -2270,10 +2369,143 @@ async def get_user_by_id( "email_verified": user.email_verified, "newsletter_subscribed": user.newsletter_subscribed, "lead_sources": user.lead_sources, + "member_since": user.member_since.isoformat() if user.member_since else None, "created_at": user.created_at.isoformat() if user.created_at else None, "updated_at": user.updated_at.isoformat() if user.updated_at else None } +@api_router.get("/admin/users/{user_id}/transactions") +async def get_user_transactions( + user_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("users.view")) +): + """ + Get a specific user's transaction history (admin only). + Returns subscriptions and donations for the specified user. + """ + # Verify user exists + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Get user's subscriptions with plan details + subscriptions = db.query(Subscription).filter( + Subscription.user_id == user_id + ).order_by(Subscription.created_at.desc()).all() + + subscription_list = [] + for sub in subscriptions: + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == sub.plan_id).first() + subscription_list.append({ + "id": str(sub.id), + "type": "subscription", + "description": plan.name if plan else "Subscription", + "amount_cents": sub.amount_paid_cents or (sub.base_subscription_cents + sub.donation_cents), + "base_amount_cents": sub.base_subscription_cents, + "donation_cents": sub.donation_cents, + "status": sub.status.value if sub.status else "unknown", + "payment_method": sub.payment_method, + "card_brand": sub.card_brand, + "card_last4": sub.card_last4, + "stripe_receipt_url": sub.stripe_receipt_url, + "created_at": sub.created_at.isoformat() if sub.created_at else None, + "payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None, + "start_date": sub.start_date.isoformat() if sub.start_date else None, + "end_date": sub.end_date.isoformat() if sub.end_date else None, + "billing_cycle": plan.billing_cycle if plan else None, + "manual_payment": sub.manual_payment, + "manual_payment_notes": sub.manual_payment_notes + }) + + # Get user's donations + donations = db.query(Donation).filter( + Donation.user_id == user_id + ).order_by(Donation.created_at.desc()).all() + + donation_list = [] + for don in donations: + donation_list.append({ + "id": str(don.id), + "type": "donation", + "description": "Donation", + "amount_cents": don.amount_cents, + "status": don.status.value if don.status else "unknown", + "payment_method": don.payment_method, + "card_brand": don.card_brand, + "card_last4": don.card_last4, + "stripe_receipt_url": don.stripe_receipt_url, + "created_at": don.created_at.isoformat() if don.created_at else None, + "payment_completed_at": don.payment_completed_at.isoformat() if don.payment_completed_at else None, + "notes": don.notes + }) + + return { + "user_id": str(user.id), + "user_name": f"{user.first_name} {user.last_name}", + "subscriptions": subscription_list, + "donations": donation_list, + "total_subscription_amount_cents": sum(s["amount_cents"] or 0 for s in subscription_list), + "total_donation_amount_cents": sum(d["amount_cents"] or 0 for d in donation_list) + } + +@api_router.put("/admin/users/{user_id}") +async def update_user_profile( + user_id: str, + request: AdminUpdateUserRequest, + db: Session = Depends(get_db), + current_user: User = Depends(require_permission("users.edit")) +): + """Update user profile fields (admin only)""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + # Update basic personal information + if request.first_name is not None: + user.first_name = request.first_name + if request.last_name is not None: + user.last_name = request.last_name + if request.phone is not None: + user.phone = request.phone + if request.address is not None: + user.address = request.address + if request.city is not None: + user.city = request.city + if request.state is not None: + user.state = request.state + if request.zipcode is not None: + user.zipcode = request.zipcode + if request.date_of_birth is not None: + user.date_of_birth = request.date_of_birth + + # Update member_since (admin only) + if request.member_since is not None: + user.member_since = request.member_since + + # Update partner information + if request.partner_first_name is not None: + user.partner_first_name = request.partner_first_name + if request.partner_last_name is not None: + user.partner_last_name = request.partner_last_name + if request.partner_is_member is not None: + user.partner_is_member = request.partner_is_member + if request.partner_plan_to_become_member is not None: + user.partner_plan_to_become_member = request.partner_plan_to_become_member + if request.referred_by_member_name is not None: + user.referred_by_member_name = request.referred_by_member_name + + user.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(user) + + logger.info(f"Admin {current_user.email} updated profile for user {user.email}") + + return { + "message": "User profile updated successfully", + "user_id": str(user.id) + } + @api_router.put("/admin/users/{user_id}/validate") async def validate_user( user_id: str, @@ -2472,6 +2704,9 @@ async def activate_payment_manually( # 6. Activate user user.status = UserStatus.active set_user_role(user, UserRole.member, db) + # Set member_since only if not already set (first time activation) + if not user.member_since: + user.member_since = datetime.now(timezone.utc) user.updated_at = datetime.now(timezone.utc) # 7. Commit @@ -2527,6 +2762,102 @@ async def admin_reset_user_password( return {"message": f"Password reset for {user.email}. Temporary password emailed."} +@api_router.put("/admin/users/{user_id}/role") +async def change_user_role( + user_id: str, + request: ChangeRoleRequest, + current_user: User = Depends(require_permission("users.edit")), + db: Session = Depends(get_db) +): + """ + Change an existing user's role with privilege escalation prevention. + + Requires: users.edit permission + + Rules: + - Superadmin: Can assign any role (including superadmin) + - Admin: Can assign admin, finance, member, guest, and non-elevated custom roles + - Admin CANNOT assign: superadmin or custom roles with elevated permissions + - Users CANNOT change their own role + """ + + # 1. Fetch target user + target_user = db.query(User).filter(User.id == user_id).first() + if not target_user: + raise HTTPException(status_code=404, detail="User not found") + + # 2. Prevent self-role-change + if str(target_user.id) == str(current_user.id): + raise HTTPException( + status_code=403, + detail="You cannot change your own role" + ) + + # 3. Validate new role + if request.role not in ['guest', 'member', 'admin', 'finance', 'superadmin']: + raise HTTPException(status_code=400, detail="Invalid role") + + # 4. Privilege escalation check + if current_user.role != 'superadmin': + # Non-superadmin cannot assign superadmin role + if request.role == 'superadmin': + raise HTTPException( + status_code=403, + detail="Only superadmin can assign superadmin role" + ) + + # Check custom role elevation + if request.role_id: + custom_role = db.query(Role).filter(Role.id == request.role_id).first() + if not custom_role: + raise HTTPException(status_code=404, detail="Custom role not found") + + # Check if custom role has elevated permissions + elevated_permissions = ['users.delete', 'roles.create', 'roles.edit', + 'roles.delete', 'permissions.edit'] + role_perms = db.query(Permission.name).join(RolePermission).filter( + RolePermission.role_id == custom_role.id, + Permission.name.in_(elevated_permissions) + ).all() + + if role_perms: + raise HTTPException( + status_code=403, + detail=f"Cannot assign role with elevated permissions: {custom_role.name}" + ) + + # 5. Update role with audit trail + old_role = target_user.role + old_role_id = target_user.role_id + + target_user.role = request.role + target_user.role_id = request.role_id if request.role_id else None + target_user.role_changed_at = datetime.now(timezone.utc) + target_user.role_changed_by = current_user.id + target_user.updated_at = datetime.now(timezone.utc) + + db.commit() + db.refresh(target_user) + + # Log admin action + logger.info( + f"Admin {current_user.email} changed role for user {target_user.email} " + f"from {old_role} to {request.role}" + ) + + return { + "message": f"Role changed from {old_role} to {request.role}", + "user": { + "id": str(target_user.id), + "email": target_user.email, + "name": f"{target_user.first_name} {target_user.last_name}", + "old_role": old_role, + "new_role": target_user.role, + "changed_by": f"{current_user.first_name} {current_user.last_name}", + "changed_at": target_user.role_changed_at.isoformat() + } + } + @api_router.post("/admin/users/{user_id}/resend-verification") async def admin_resend_verification( user_id: str, @@ -4425,8 +4756,17 @@ async def get_all_subscriptions( "donation_cents": sub.donation_cents, "payment_method": sub.payment_method, "stripe_subscription_id": sub.stripe_subscription_id, + "stripe_customer_id": sub.stripe_customer_id, "created_at": sub.created_at, - "updated_at": sub.updated_at + "updated_at": sub.updated_at, + # Stripe transaction metadata + "stripe_payment_intent_id": sub.stripe_payment_intent_id, + "stripe_charge_id": sub.stripe_charge_id, + "stripe_invoice_id": sub.stripe_invoice_id, + "payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None, + "card_last4": sub.card_last4, + "card_brand": sub.card_brand, + "stripe_receipt_url": sub.stripe_receipt_url } for sub in subscriptions] @api_router.get("/admin/subscriptions/stats") @@ -4666,7 +5006,15 @@ async def get_donations( "donor_email": d.donor_email or (d.user.email if d.user else None), "payment_method": d.payment_method, "notes": d.notes, - "created_at": d.created_at.isoformat() + "created_at": d.created_at.isoformat(), + # Stripe transaction metadata + "stripe_payment_intent_id": d.stripe_payment_intent_id, + "stripe_charge_id": d.stripe_charge_id, + "stripe_customer_id": d.stripe_customer_id, + "payment_completed_at": d.payment_completed_at.isoformat() if d.payment_completed_at else None, + "card_last4": d.card_last4, + "card_brand": d.card_brand, + "stripe_receipt_url": d.stripe_receipt_url } for d in donations] @api_router.get("/admin/donations/stats") @@ -5980,7 +6328,15 @@ async def create_checkout( # Create Stripe Checkout Session import stripe - stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + # Try to get Stripe API key from database first, then fall back to environment + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + + if not stripe_key: + raise HTTPException(status_code=500, detail="Stripe API key not configured") + + stripe.api_key = stripe_key mode = "subscription" if stripe_interval else "payment" @@ -6055,7 +6411,15 @@ async def create_donation_checkout( # Create Stripe Checkout Session for one-time payment import stripe - stripe.api_key = os.getenv("STRIPE_SECRET_KEY") + # Try to get Stripe API key from database first, then fall back to environment + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + + if not stripe_key: + raise HTTPException(status_code=500, detail="Stripe API key not configured") + + stripe.api_key = stripe_key checkout_session = stripe.checkout.Session.create( payment_method_types=['card'], @@ -6197,8 +6561,8 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): raise HTTPException(status_code=400, detail="Missing stripe-signature header") try: - # Verify webhook signature - event = verify_webhook_signature(payload, sig_header) + # Verify webhook signature (pass db for reading webhook secret from database) + event = verify_webhook_signature(payload, sig_header, db) except ValueError as e: logger.error(f"Webhook signature verification failed: {str(e)}") raise HTTPException(status_code=400, detail=str(e)) @@ -6214,23 +6578,67 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): donation = db.query(Donation).filter(Donation.id == donation_id).first() if donation: + # Get Stripe API key from database + import stripe + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + stripe.api_key = stripe_key + + # Extract basic payment info + payment_intent_id = session.get('payment_intent') donation.status = DonationStatus.completed - donation.stripe_payment_intent_id = session.get('payment_intent') + donation.stripe_payment_intent_id = payment_intent_id + donation.stripe_customer_id = session.get('customer') donation.payment_method = 'card' + donation.payment_completed_at = datetime.fromtimestamp(session.get('created'), tz=timezone.utc) + + # Capture donor email and name from Stripe session if not already set + if not donation.donor_email and session.get('customer_details'): + customer_details = session.get('customer_details') + donation.donor_email = customer_details.get('email') + if not donation.donor_name and customer_details.get('name'): + donation.donor_name = customer_details.get('name') + + # Retrieve PaymentIntent to get charge details + try: + if payment_intent_id: + payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id) + + # Get charge ID from latest_charge + charge_id = payment_intent.latest_charge if hasattr(payment_intent, 'latest_charge') else None + + if charge_id: + # Retrieve the charge to get full details + charge = stripe.Charge.retrieve(charge_id) + donation.stripe_charge_id = charge.id + donation.stripe_receipt_url = charge.receipt_url + + # Get card details + if hasattr(charge, 'payment_method_details') and charge.payment_method_details and charge.payment_method_details.card: + card = charge.payment_method_details.card + donation.card_last4 = card.last4 + donation.card_brand = card.brand.capitalize() # visa -> Visa + except Exception as e: + logger.error(f"Failed to retrieve Stripe payment details for donation: {str(e)}") + donation.updated_at = datetime.now(timezone.utc) db.commit() - # Send thank you email - try: - from email_service import send_donation_thank_you_email - donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend" - await send_donation_thank_you_email( - donation.donor_email, - donor_first_name, - donation.amount_cents - ) - except Exception as e: - logger.error(f"Failed to send donation thank you email: {str(e)}") + # Send thank you email only if donor_email exists + if donation.donor_email: + try: + from email_service import send_donation_thank_you_email + donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend" + await send_donation_thank_you_email( + donation.donor_email, + donor_first_name, + donation.amount_cents + ) + except Exception as e: + logger.error(f"Failed to send donation thank you email: {str(e)}") + else: + logger.warning(f"Skipping thank you email for donation {donation.id}: no donor email") logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})") else: @@ -6260,15 +6668,26 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): ).first() if not existing_subscription: + # Get Stripe API key from database + import stripe + stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True) + if not stripe_key: + stripe_key = os.getenv("STRIPE_SECRET_KEY") + stripe.api_key = stripe_key + # Calculate subscription period using custom billing cycle if enabled from payment_service import calculate_subscription_period start_date, end_date = calculate_subscription_period(plan) + # Extract basic payment info + payment_intent_id = session.get('payment_intent') + subscription_id = session.get("subscription") + # Create subscription record with donation tracking subscription = Subscription( user_id=user.id, plan_id=plan.id, - stripe_subscription_id=session.get("subscription"), + stripe_subscription_id=subscription_id, stripe_customer_id=session.get("customer"), status=SubscriptionStatus.active, start_date=start_date, @@ -6276,13 +6695,48 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): amount_paid_cents=total_amount, base_subscription_cents=base_amount or plan.minimum_price_cents, donation_cents=donation_amount, - payment_method="stripe" + payment_method="stripe", + stripe_payment_intent_id=payment_intent_id, + payment_completed_at=datetime.fromtimestamp(session.get('created'), tz=timezone.utc) ) + + # Retrieve PaymentIntent and Subscription to get detailed transaction info + try: + if payment_intent_id: + payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id) + + # Get charge ID from latest_charge + charge_id = payment_intent.latest_charge if hasattr(payment_intent, 'latest_charge') else None + + if charge_id: + # Retrieve the charge to get full details + charge = stripe.Charge.retrieve(charge_id) + subscription.stripe_charge_id = charge.id + subscription.stripe_receipt_url = charge.receipt_url + + # Get card details + if hasattr(charge, 'payment_method_details') and charge.payment_method_details and charge.payment_method_details.card: + card = charge.payment_method_details.card + subscription.card_last4 = card.last4 + subscription.card_brand = card.brand.capitalize() # visa -> Visa + + # Get invoice ID from subscription + if subscription_id: + stripe_subscription = stripe.Subscription.retrieve(subscription_id) + if hasattr(stripe_subscription, 'latest_invoice') and stripe_subscription.latest_invoice: + subscription.stripe_invoice_id = stripe_subscription.latest_invoice + + except Exception as e: + logger.error(f"Failed to retrieve Stripe payment details for subscription: {str(e)}") + db.add(subscription) # Update user status and role user.status = UserStatus.active set_user_role(user, UserRole.member, db) + # Set member_since only if not already set (first time activation) + if not user.member_since: + user.member_since = datetime.now(timezone.utc) user.updated_at = datetime.now(timezone.utc) db.commit() @@ -6298,6 +6752,277 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): return {"status": "success"} +# ============================================================================ +# ADMIN SETTINGS ENDPOINTS +# ============================================================================ + +# Helper functions for system settings +def get_setting(db: Session, key: str, decrypt: bool = False) -> str | None: + """ + Get a system setting value from database. + + Args: + db: Database session + key: Setting key to retrieve + decrypt: If True and setting_type is 'encrypted', decrypt the value + + Returns: + Setting value or None if not found + """ + from models import SystemSettings, SettingType + from encryption_service import get_encryption_service + + setting = db.query(SystemSettings).filter(SystemSettings.setting_key == key).first() + if not setting: + return None + + value = setting.setting_value + if decrypt and setting.setting_type == SettingType.encrypted and value: + try: + encryption_service = get_encryption_service() + value = encryption_service.decrypt(value) + except Exception as e: + print(f"Failed to decrypt setting {key}: {e}") + return None + + return value + + +def set_setting( + db: Session, + key: str, + value: str, + user_id: str, + setting_type: str = "plaintext", + description: str = None, + is_sensitive: bool = False, + encrypt: bool = False +) -> None: + """ + Set a system setting value in database. + + Args: + db: Database session + key: Setting key + value: Setting value + user_id: ID of user making the change + setting_type: Type of setting (plaintext, encrypted, json) + description: Human-readable description + is_sensitive: Whether this is sensitive data + encrypt: If True, encrypt the value before storing + """ + from models import SystemSettings, SettingType + from encryption_service import get_encryption_service + + # Encrypt value if requested + if encrypt and value: + encryption_service = get_encryption_service() + value = encryption_service.encrypt(value) + setting_type = "encrypted" + + # Find or create setting + setting = db.query(SystemSettings).filter(SystemSettings.setting_key == key).first() + + if setting: + # Update existing + setting.setting_value = value + setting.setting_type = SettingType[setting_type] + setting.updated_by = user_id + setting.updated_at = datetime.now(timezone.utc) + if description: + setting.description = description + setting.is_sensitive = is_sensitive + else: + # Create new + setting = SystemSettings( + setting_key=key, + setting_value=value, + setting_type=SettingType[setting_type], + description=description, + updated_by=user_id, + is_sensitive=is_sensitive + ) + db.add(setting) + + db.commit() + +@api_router.get("/admin/settings/stripe/status") +async def get_stripe_status( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Get Stripe integration status (superadmin only). + + Returns: + - configured: Whether credentials exist in database + - secret_key_prefix: First 10 chars of secret key (for verification) + - webhook_configured: Whether webhook secret exists + - environment: test or live (based on key prefix) + - webhook_url: Full webhook URL for Stripe configuration + """ + import os + + # Read from database + secret_key = get_setting(db, 'stripe_secret_key', decrypt=True) + webhook_secret = get_setting(db, 'stripe_webhook_secret', decrypt=True) + + configured = bool(secret_key) + environment = 'unknown' + + if secret_key: + if secret_key.startswith('sk_test_'): + environment = 'test' + elif secret_key.startswith('sk_live_'): + environment = 'live' + + # Get backend URL from environment for webhook URL + # Try multiple environment variable patterns for flexibility + backend_url = ( + os.environ.get('BACKEND_URL') or + os.environ.get('API_URL') or + f"http://{os.environ.get('HOST', 'localhost')}:{os.environ.get('PORT', '8000')}" + ) + webhook_url = f"{backend_url}/api/webhooks/stripe" + + return { + "configured": configured, + "secret_key_prefix": secret_key[:10] if secret_key else None, + "secret_key_set": bool(secret_key), + "webhook_secret_set": bool(webhook_secret), + "environment": environment, + "webhook_url": webhook_url, + "instructions": { + "location": "Database (system_settings table)", + "required_settings": [ + "stripe_secret_key (sk_test_... or sk_live_...)", + "stripe_webhook_secret (whsec_...)" + ], + "restart_required": "No - changes take effect immediately" + } + } + +@api_router.post("/admin/settings/stripe/test-connection") +async def test_stripe_connection( + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Test Stripe API connection (superadmin only). + + Performs a simple API call to verify credentials work. + """ + import stripe + + # Read from database + secret_key = get_setting(db, 'stripe_secret_key', decrypt=True) + + if not secret_key: + raise HTTPException( + status_code=400, + detail="STRIPE_SECRET_KEY not configured in database. Please configure Stripe settings first." + ) + + try: + stripe.api_key = secret_key + + # Make a simple API call to test connection + balance = stripe.Balance.retrieve() + + return { + "success": True, + "message": "Stripe connection successful", + "environment": "test" if secret_key.startswith('sk_test_') else "live", + "balance": { + "available": balance.available, + "pending": balance.pending + } + } + except stripe.error.AuthenticationError as e: + raise HTTPException( + status_code=401, + detail=f"Stripe authentication failed: {str(e)}" + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Stripe connection test failed: {str(e)}" + ) + + +class UpdateStripeSettingsRequest(BaseModel): + """Request model for updating Stripe settings""" + secret_key: str = Field(..., min_length=1, description="Stripe secret key (sk_test_... or sk_live_...)") + webhook_secret: str = Field(..., min_length=1, description="Stripe webhook secret (whsec_...)") + + +@api_router.put("/admin/settings/stripe") +async def update_stripe_settings( + request: UpdateStripeSettingsRequest, + current_user: User = Depends(get_current_superadmin), + db: Session = Depends(get_db) +): + """ + Update Stripe integration settings (superadmin only). + + Stores Stripe credentials encrypted in the database. + Changes take effect immediately without server restart. + """ + # Validate secret key format + if not (request.secret_key.startswith('sk_test_') or request.secret_key.startswith('sk_live_')): + raise HTTPException( + status_code=400, + detail="Invalid Stripe secret key format. Must start with 'sk_test_' or 'sk_live_'" + ) + + # Validate webhook secret format + if not request.webhook_secret.startswith('whsec_'): + raise HTTPException( + status_code=400, + detail="Invalid Stripe webhook secret format. Must start with 'whsec_'" + ) + + try: + # Store secret key (encrypted) + set_setting( + db=db, + key='stripe_secret_key', + value=request.secret_key, + user_id=str(current_user.id), + description='Stripe API secret key for payment processing', + is_sensitive=True, + encrypt=True + ) + + # Store webhook secret (encrypted) + set_setting( + db=db, + key='stripe_webhook_secret', + value=request.webhook_secret, + user_id=str(current_user.id), + description='Stripe webhook secret for verifying webhook signatures', + is_sensitive=True, + encrypt=True + ) + + # Determine environment + environment = 'test' if request.secret_key.startswith('sk_test_') else 'live' + + return { + "success": True, + "message": "Stripe settings updated successfully", + "environment": environment, + "updated_at": datetime.now(timezone.utc).isoformat(), + "updated_by": f"{current_user.first_name} {current_user.last_name}" + } + + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Failed to update Stripe settings: {str(e)}" + ) + + # Include the router in the main app app.include_router(api_router)