dev #25
13
.env.example
13
.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_...
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
48
alembic/versions/4fa11836f7fd_add_role_audit_fields.py
Normal file
48
alembic/versions/4fa11836f7fd_add_role_audit_fields.py
Normal file
@@ -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')
|
||||
@@ -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')
|
||||
68
alembic/versions/ec4cb4a49cde_add_system_settings_table.py
Normal file
68
alembic/versions/ec4cb4a49cde_add_system_settings_table.py
Normal file
@@ -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')
|
||||
122
encryption_service.py
Normal file
122
encryption_service.py
Normal file
@@ -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
|
||||
57
models.py
57
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'),
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
765
server.py
765
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user