Compare commits
29 Commits
5ab0038c0a
...
dav-prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 8695944ef8 | |||
|
|
ea87b3f6ee | ||
| 7d61eddcef | |||
|
|
b29bb641f5 | ||
|
|
d322d1334f | ||
|
|
ece1e62913 | ||
|
|
d3a0cabede | ||
| a5fc42b353 | |||
| 37b1ab75df | |||
| f915976cb3 | |||
| 9c5aafc57b | |||
| 3755a71ed8 | |||
| b2293a5588 | |||
| 9f29bf05d8 | |||
| b44d55919e | |||
| 1a6341a94c | |||
| 727cbf4b5c | |||
| 9c3f3c88b8 | |||
| 849a6a32af | |||
| 69b8185414 | |||
| f5f8ca8dc6 | |||
| 661a4cbb7c | |||
| a01a8b9915 | |||
| e126cb988c | |||
| fd988241a1 | |||
| c28eddca67 | |||
| e20542ccdc | |||
| b3f1f5f789 | |||
| 1da045f73f |
83
.dockerignore
Normal file
83
.dockerignore
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# Git
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env/
|
||||||
|
.venv/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
|
||||||
|
# Environment files (will be mounted or passed via env vars)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
*.env
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Database
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Alembic
|
||||||
|
alembic/versions/__pycache__/
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose*.yml
|
||||||
|
.docker/
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
*.md
|
||||||
|
docs/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Uploads (will be mounted as volume)
|
||||||
|
uploads/
|
||||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,5 +1,3 @@
|
|||||||
.env
|
|
||||||
.venv
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Python Backend .gitignore
|
# Python Backend .gitignore
|
||||||
# For FastAPI + PostgreSQL + Cloudflare R2 + Stripe
|
# For FastAPI + PostgreSQL + Cloudflare R2 + Stripe
|
||||||
@@ -10,7 +8,6 @@
|
|||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
.envrc
|
.envrc
|
||||||
.sh
|
|
||||||
|
|
||||||
# ===== Python =====
|
# ===== Python =====
|
||||||
# Byte-compiled / optimized / DLL files
|
# Byte-compiled / optimized / DLL files
|
||||||
@@ -248,6 +245,9 @@ temp_uploads/
|
|||||||
tmp/
|
tmp/
|
||||||
temporary/
|
temporary/
|
||||||
|
|
||||||
|
# Generated SQL files (from scripts)
|
||||||
|
create_superadmin.sql
|
||||||
|
|
||||||
# CSV imports
|
# CSV imports
|
||||||
imports/*.csv
|
imports/*.csv
|
||||||
!imports/.gitkeep
|
!imports/.gitkeep
|
||||||
|
|||||||
42
Dockerfile
42
Dockerfile
@@ -1,20 +1,40 @@
|
|||||||
# Use an official Python image (Linux)
|
# Backend Dockerfile - FastAPI with Python
|
||||||
FROM python:3.12-slim
|
FROM python:3.11-slim
|
||||||
|
|
||||||
# Set a working directory
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
ENV PYTHONPATH=/app
|
||||||
|
|
||||||
|
# Set work directory
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy dependency list
|
# Install system dependencies
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
gcc \
|
||||||
|
libpq-dev \
|
||||||
|
curl \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
|
pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
# Install dependencies
|
# Copy application code
|
||||||
RUN pip3 install --no-cache-dir -r requirements.txt
|
|
||||||
|
|
||||||
# Copy the rest of the project
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Expose port (whatever your backend runs on)
|
# Create non-root user for security
|
||||||
|
RUN adduser --disabled-password --gecos '' appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
# Expose port
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
# Run exactly your command
|
# Health check
|
||||||
CMD ["python", "-m", "uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8000/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,76 @@
|
|||||||
|
"""add_stripe_transaction_metadata
|
||||||
|
|
||||||
|
Revision ID: 956ea1628264
|
||||||
|
Revises: ec4cb4a49cde
|
||||||
|
Create Date: 2026-01-20 22:00:01.806931
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '956ea1628264'
|
||||||
|
down_revision: Union[str, None] = 'ec4cb4a49cde'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Add Stripe transaction metadata to subscriptions table
|
||||||
|
op.add_column('subscriptions', sa.Column('stripe_payment_intent_id', sa.String(), nullable=True))
|
||||||
|
op.add_column('subscriptions', sa.Column('stripe_charge_id', sa.String(), nullable=True))
|
||||||
|
op.add_column('subscriptions', sa.Column('stripe_invoice_id', sa.String(), nullable=True))
|
||||||
|
op.add_column('subscriptions', sa.Column('payment_completed_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column('subscriptions', sa.Column('card_last4', sa.String(4), nullable=True))
|
||||||
|
op.add_column('subscriptions', sa.Column('card_brand', sa.String(20), nullable=True))
|
||||||
|
op.add_column('subscriptions', sa.Column('stripe_receipt_url', sa.String(), nullable=True))
|
||||||
|
|
||||||
|
# Add indexes for Stripe transaction IDs in subscriptions
|
||||||
|
op.create_index('idx_subscriptions_payment_intent', 'subscriptions', ['stripe_payment_intent_id'])
|
||||||
|
op.create_index('idx_subscriptions_charge_id', 'subscriptions', ['stripe_charge_id'])
|
||||||
|
op.create_index('idx_subscriptions_invoice_id', 'subscriptions', ['stripe_invoice_id'])
|
||||||
|
|
||||||
|
# Add Stripe transaction metadata to donations table
|
||||||
|
op.add_column('donations', sa.Column('stripe_charge_id', sa.String(), nullable=True))
|
||||||
|
op.add_column('donations', sa.Column('stripe_customer_id', sa.String(), nullable=True))
|
||||||
|
op.add_column('donations', sa.Column('payment_completed_at', sa.DateTime(timezone=True), nullable=True))
|
||||||
|
op.add_column('donations', sa.Column('card_last4', sa.String(4), nullable=True))
|
||||||
|
op.add_column('donations', sa.Column('card_brand', sa.String(20), nullable=True))
|
||||||
|
op.add_column('donations', sa.Column('stripe_receipt_url', sa.String(), nullable=True))
|
||||||
|
|
||||||
|
# Add indexes for Stripe transaction IDs in donations
|
||||||
|
op.create_index('idx_donations_payment_intent', 'donations', ['stripe_payment_intent_id'])
|
||||||
|
op.create_index('idx_donations_charge_id', 'donations', ['stripe_charge_id'])
|
||||||
|
op.create_index('idx_donations_customer_id', 'donations', ['stripe_customer_id'])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Remove indexes from donations
|
||||||
|
op.drop_index('idx_donations_customer_id', table_name='donations')
|
||||||
|
op.drop_index('idx_donations_charge_id', table_name='donations')
|
||||||
|
op.drop_index('idx_donations_payment_intent', table_name='donations')
|
||||||
|
|
||||||
|
# Remove columns from donations
|
||||||
|
op.drop_column('donations', 'stripe_receipt_url')
|
||||||
|
op.drop_column('donations', 'card_brand')
|
||||||
|
op.drop_column('donations', 'card_last4')
|
||||||
|
op.drop_column('donations', 'payment_completed_at')
|
||||||
|
op.drop_column('donations', 'stripe_customer_id')
|
||||||
|
op.drop_column('donations', 'stripe_charge_id')
|
||||||
|
|
||||||
|
# Remove indexes from subscriptions
|
||||||
|
op.drop_index('idx_subscriptions_invoice_id', table_name='subscriptions')
|
||||||
|
op.drop_index('idx_subscriptions_charge_id', table_name='subscriptions')
|
||||||
|
op.drop_index('idx_subscriptions_payment_intent', table_name='subscriptions')
|
||||||
|
|
||||||
|
# Remove columns from subscriptions
|
||||||
|
op.drop_column('subscriptions', 'stripe_receipt_url')
|
||||||
|
op.drop_column('subscriptions', 'card_brand')
|
||||||
|
op.drop_column('subscriptions', 'card_last4')
|
||||||
|
op.drop_column('subscriptions', 'payment_completed_at')
|
||||||
|
op.drop_column('subscriptions', 'stripe_invoice_id')
|
||||||
|
op.drop_column('subscriptions', 'stripe_charge_id')
|
||||||
|
op.drop_column('subscriptions', 'stripe_payment_intent_id')
|
||||||
@@ -1,38 +1,15 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""
|
||||||
Create Superadmin User Script
|
Create Superadmin User Script
|
||||||
Generates a superadmin user with hashed password for LOAF membership platform
|
Directly creates a superadmin user in the database for LOAF membership platform
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import bcrypt
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from getpass import getpass
|
from getpass import getpass
|
||||||
|
|
||||||
def generate_password_hash(password: str) -> str:
|
# Add the backend directory to path for imports
|
||||||
"""Generate bcrypt hash for password"""
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
|
||||||
|
|
||||||
def generate_sql(email: str, password_hash: str, first_name: str, last_name: str) -> str:
|
|
||||||
"""Generate SQL INSERT statement"""
|
|
||||||
return f"""
|
|
||||||
-- Create Superadmin User
|
|
||||||
INSERT INTO users (
|
|
||||||
id, email, password_hash, first_name, last_name,
|
|
||||||
status, role, email_verified, created_at, updated_at
|
|
||||||
) VALUES (
|
|
||||||
gen_random_uuid(),
|
|
||||||
'{email}',
|
|
||||||
'{password_hash}',
|
|
||||||
'{first_name}',
|
|
||||||
'{last_name}',
|
|
||||||
'active',
|
|
||||||
'superadmin',
|
|
||||||
true,
|
|
||||||
NOW(),
|
|
||||||
NOW()
|
|
||||||
);
|
|
||||||
"""
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
@@ -40,6 +17,15 @@ def main():
|
|||||||
print("=" * 70)
|
print("=" * 70)
|
||||||
print()
|
print()
|
||||||
|
|
||||||
|
# Check for DATABASE_URL
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
database_url = os.getenv("DATABASE_URL")
|
||||||
|
if not database_url:
|
||||||
|
print("❌ DATABASE_URL not found in environment or .env file")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
# Get user input
|
# Get user input
|
||||||
email = input("Email address: ").strip()
|
email = input("Email address: ").strip()
|
||||||
if not email or '@' not in email:
|
if not email or '@' not in email:
|
||||||
@@ -68,31 +54,89 @@ def main():
|
|||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
print()
|
print()
|
||||||
print("Generating password hash...")
|
print("Creating superadmin user...")
|
||||||
password_hash = generate_password_hash(password)
|
|
||||||
|
|
||||||
print("✅ Password hash generated")
|
try:
|
||||||
print()
|
# Import database dependencies
|
||||||
print("=" * 70)
|
from sqlalchemy import create_engine, text
|
||||||
print("SQL STATEMENT")
|
from passlib.context import CryptContext
|
||||||
print("=" * 70)
|
|
||||||
|
|
||||||
sql = generate_sql(email, password_hash, first_name, last_name)
|
# Create password hash
|
||||||
print(sql)
|
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||||
|
password_hash = pwd_context.hash(password)
|
||||||
|
|
||||||
# Save to file
|
# Connect to database
|
||||||
output_file = "create_superadmin.sql"
|
engine = create_engine(database_url)
|
||||||
with open(output_file, 'w') as f:
|
|
||||||
f.write(sql)
|
|
||||||
|
|
||||||
print("=" * 70)
|
with engine.connect() as conn:
|
||||||
print(f"✅ SQL saved to: {output_file}")
|
# Check if user already exists
|
||||||
print()
|
result = conn.execute(
|
||||||
print("Run this command to create the user:")
|
text("SELECT id FROM users WHERE email = :email"),
|
||||||
print(f" psql -U postgres -d loaf_new -f {output_file}")
|
{"email": email}
|
||||||
print()
|
)
|
||||||
print("Or copy the SQL above and run it directly in psql")
|
if result.fetchone():
|
||||||
print("=" * 70)
|
print(f"❌ User with email '{email}' already exists")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Insert superadmin user
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
INSERT INTO users (
|
||||||
|
id, email, password_hash, first_name, last_name,
|
||||||
|
phone, address, city, state, zipcode, date_of_birth,
|
||||||
|
status, role, email_verified,
|
||||||
|
newsletter_subscribed, accepts_tos,
|
||||||
|
created_at, updated_at
|
||||||
|
) VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
:email,
|
||||||
|
:password_hash,
|
||||||
|
:first_name,
|
||||||
|
:last_name,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'1990-01-01',
|
||||||
|
'active',
|
||||||
|
'superadmin',
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
"""),
|
||||||
|
{
|
||||||
|
"email": email,
|
||||||
|
"password_hash": password_hash,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name
|
||||||
|
}
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 70)
|
||||||
|
print("✅ Superadmin user created successfully!")
|
||||||
|
print("=" * 70)
|
||||||
|
print()
|
||||||
|
print(f" Email: {email}")
|
||||||
|
print(f" Name: {first_name} {last_name}")
|
||||||
|
print(f" Role: superadmin")
|
||||||
|
print(f" Status: active")
|
||||||
|
print()
|
||||||
|
print("You can now log in with these credentials.")
|
||||||
|
print("=" * 70)
|
||||||
|
|
||||||
|
except ImportError as e:
|
||||||
|
print(f"❌ Missing dependency: {e}")
|
||||||
|
print(" Run: pip install sqlalchemy psycopg2-binary passlib python-dotenv")
|
||||||
|
sys.exit(1)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Database error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
try:
|
try:
|
||||||
@@ -100,6 +144,3 @@ if __name__ == "__main__":
|
|||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("\n\n❌ Cancelled by user")
|
print("\n\n❌ Cancelled by user")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
except Exception as e:
|
|
||||||
print(f"\n❌ Error: {e}")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|||||||
@@ -1,14 +0,0 @@
|
|||||||
services:
|
|
||||||
backend:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile # Use Dockerfile.prod for production
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: ${DATABASE_URL}
|
|
||||||
volumes:
|
|
||||||
- .:/app # sync code for hot reload
|
|
||||||
|
|
||||||
@@ -94,6 +94,30 @@ BEGIN;
|
|||||||
-- SECTION 2: Create Core Tables
|
-- SECTION 2: Create Core Tables
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
|
|
||||||
|
-- Import Jobs table (must be created before users due to FK reference)
|
||||||
|
CREATE TABLE IF NOT EXISTS import_jobs (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
|
||||||
|
filename VARCHAR NOT NULL,
|
||||||
|
status importjobstatus NOT NULL DEFAULT 'processing',
|
||||||
|
total_rows INTEGER DEFAULT 0,
|
||||||
|
processed_rows INTEGER DEFAULT 0,
|
||||||
|
success_count INTEGER DEFAULT 0,
|
||||||
|
error_count INTEGER DEFAULT 0,
|
||||||
|
error_log JSONB DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- WordPress import enhancements
|
||||||
|
field_mapping JSONB DEFAULT '{}'::jsonb,
|
||||||
|
wordpress_metadata JSONB DEFAULT '{}'::jsonb,
|
||||||
|
imported_user_ids JSONB DEFAULT '[]'::jsonb,
|
||||||
|
rollback_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
rollback_by UUID, -- Will be updated with FK after users table exists
|
||||||
|
|
||||||
|
started_by UUID, -- Will be updated with FK after users table exists
|
||||||
|
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP WITH TIME ZONE
|
||||||
|
);
|
||||||
|
|
||||||
-- Users table
|
-- Users table
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -103,6 +127,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
password_hash VARCHAR NOT NULL,
|
password_hash VARCHAR NOT NULL,
|
||||||
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
email_verification_token VARCHAR UNIQUE,
|
email_verification_token VARCHAR UNIQUE,
|
||||||
|
email_verification_expires TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
-- Personal Information
|
-- Personal Information
|
||||||
first_name VARCHAR NOT NULL,
|
first_name VARCHAR NOT NULL,
|
||||||
@@ -113,7 +138,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
state VARCHAR(2),
|
state VARCHAR(2),
|
||||||
zipcode VARCHAR(10),
|
zipcode VARCHAR(10),
|
||||||
date_of_birth DATE,
|
date_of_birth DATE,
|
||||||
bio TEXT,
|
|
||||||
|
|
||||||
-- Profile
|
-- Profile
|
||||||
profile_photo_url VARCHAR,
|
profile_photo_url VARCHAR,
|
||||||
@@ -137,20 +161,44 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
-- Status & Role
|
-- Status & Role
|
||||||
status userstatus NOT NULL DEFAULT 'pending_email',
|
status userstatus NOT NULL DEFAULT 'pending_email',
|
||||||
role userrole NOT NULL DEFAULT 'guest',
|
role userrole NOT NULL DEFAULT 'guest',
|
||||||
role_id UUID, -- For dynamic RBAC (added in later migration)
|
role_id UUID, -- For dynamic RBAC
|
||||||
|
|
||||||
-- Rejection Tracking
|
-- Newsletter Preferences
|
||||||
rejection_reason TEXT,
|
newsletter_subscribed BOOLEAN DEFAULT TRUE,
|
||||||
rejected_at TIMESTAMP WITH TIME ZONE,
|
newsletter_publish_name BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
rejected_by UUID REFERENCES users(id),
|
newsletter_publish_photo BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
newsletter_publish_birthday BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
newsletter_publish_none BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
|
||||||
|
-- Volunteer Interests
|
||||||
|
volunteer_interests JSONB DEFAULT '[]'::jsonb,
|
||||||
|
|
||||||
|
-- Scholarship Request
|
||||||
|
scholarship_requested BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
scholarship_reason TEXT,
|
||||||
|
|
||||||
|
-- Directory Settings
|
||||||
|
show_in_directory BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
directory_email VARCHAR,
|
||||||
|
directory_bio TEXT,
|
||||||
|
directory_address VARCHAR,
|
||||||
|
directory_phone VARCHAR,
|
||||||
|
directory_dob DATE,
|
||||||
|
directory_partner_name VARCHAR,
|
||||||
|
|
||||||
|
-- Password Reset
|
||||||
|
password_reset_token VARCHAR,
|
||||||
|
password_reset_expires TIMESTAMP WITH TIME ZONE,
|
||||||
|
force_password_change BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
|
||||||
|
-- Terms of Service
|
||||||
|
accepts_tos BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
tos_accepted_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
-- Membership
|
-- Membership
|
||||||
member_since DATE,
|
member_since DATE,
|
||||||
accepts_tos BOOLEAN DEFAULT FALSE,
|
|
||||||
tos_accepted_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
newsletter_subscribed BOOLEAN DEFAULT TRUE,
|
|
||||||
|
|
||||||
-- Reminder Tracking (from migration 004)
|
-- Reminder Tracking
|
||||||
email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
email_verification_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
||||||
last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE,
|
last_email_verification_reminder_at TIMESTAMP WITH TIME ZONE,
|
||||||
event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
event_attendance_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
||||||
@@ -160,12 +208,21 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
renewal_reminders_sent INTEGER DEFAULT 0 NOT NULL,
|
||||||
last_renewal_reminder_at TIMESTAMP WITH TIME ZONE,
|
last_renewal_reminder_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Rejection Tracking
|
||||||
|
rejection_reason TEXT,
|
||||||
|
rejected_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
rejected_by UUID REFERENCES users(id),
|
||||||
|
|
||||||
-- WordPress Import Tracking
|
-- WordPress Import Tracking
|
||||||
import_source VARCHAR(50),
|
import_source VARCHAR(50),
|
||||||
import_job_id UUID REFERENCES import_jobs(id),
|
import_job_id UUID REFERENCES import_jobs(id),
|
||||||
wordpress_user_id BIGINT,
|
wordpress_user_id BIGINT,
|
||||||
wordpress_registered_date TIMESTAMP WITH TIME ZONE,
|
wordpress_registered_date TIMESTAMP WITH TIME ZONE,
|
||||||
|
|
||||||
|
-- Role Change Audit Trail
|
||||||
|
role_changed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
role_changed_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
-- Timestamps
|
-- Timestamps
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
@@ -255,11 +312,23 @@ CREATE TABLE IF NOT EXISTS subscription_plans (
|
|||||||
name VARCHAR NOT NULL,
|
name VARCHAR NOT NULL,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
price_cents INTEGER NOT NULL,
|
price_cents INTEGER NOT NULL,
|
||||||
billing_cycle VARCHAR NOT NULL DEFAULT 'annual',
|
billing_cycle VARCHAR NOT NULL DEFAULT 'yearly',
|
||||||
|
stripe_price_id VARCHAR, -- Legacy, deprecated
|
||||||
|
|
||||||
-- Configuration
|
-- Configuration
|
||||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
features JSONB DEFAULT '[]'::jsonb,
|
|
||||||
|
-- Custom billing cycle fields (for recurring date ranges like Jan 1 - Dec 31)
|
||||||
|
custom_cycle_enabled BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
|
custom_cycle_start_month INTEGER,
|
||||||
|
custom_cycle_start_day INTEGER,
|
||||||
|
custom_cycle_end_month INTEGER,
|
||||||
|
custom_cycle_end_day INTEGER,
|
||||||
|
|
||||||
|
-- Dynamic pricing fields
|
||||||
|
minimum_price_cents INTEGER DEFAULT 3000 NOT NULL,
|
||||||
|
suggested_price_cents INTEGER,
|
||||||
|
allow_donation BOOLEAN DEFAULT TRUE NOT NULL,
|
||||||
|
|
||||||
-- Timestamps
|
-- Timestamps
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -281,13 +350,21 @@ CREATE TABLE IF NOT EXISTS subscriptions (
|
|||||||
status subscriptionstatus DEFAULT 'active',
|
status subscriptionstatus DEFAULT 'active',
|
||||||
start_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
start_date TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||||
end_date TIMESTAMP WITH TIME ZONE,
|
end_date TIMESTAMP WITH TIME ZONE,
|
||||||
next_billing_date TIMESTAMP WITH TIME ZONE,
|
|
||||||
|
|
||||||
-- Payment Details
|
-- Payment Details
|
||||||
amount_paid_cents INTEGER,
|
amount_paid_cents INTEGER,
|
||||||
base_subscription_cents INTEGER NOT NULL,
|
base_subscription_cents INTEGER NOT NULL,
|
||||||
donation_cents INTEGER DEFAULT 0 NOT NULL,
|
donation_cents INTEGER DEFAULT 0 NOT NULL,
|
||||||
|
|
||||||
|
-- Stripe transaction metadata (for validation and audit)
|
||||||
|
stripe_payment_intent_id VARCHAR,
|
||||||
|
stripe_charge_id VARCHAR,
|
||||||
|
stripe_invoice_id VARCHAR,
|
||||||
|
payment_completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
card_last4 VARCHAR(4),
|
||||||
|
card_brand VARCHAR(20),
|
||||||
|
stripe_receipt_url VARCHAR,
|
||||||
|
|
||||||
-- Manual Payment Support
|
-- Manual Payment Support
|
||||||
manual_payment BOOLEAN DEFAULT FALSE NOT NULL,
|
manual_payment BOOLEAN DEFAULT FALSE NOT NULL,
|
||||||
manual_payment_notes TEXT,
|
manual_payment_notes TEXT,
|
||||||
@@ -319,6 +396,14 @@ CREATE TABLE IF NOT EXISTS donations (
|
|||||||
stripe_payment_intent_id VARCHAR,
|
stripe_payment_intent_id VARCHAR,
|
||||||
payment_method VARCHAR,
|
payment_method VARCHAR,
|
||||||
|
|
||||||
|
-- Stripe transaction metadata (for validation and audit)
|
||||||
|
stripe_charge_id VARCHAR,
|
||||||
|
stripe_customer_id VARCHAR,
|
||||||
|
payment_completed_at TIMESTAMP WITH TIME ZONE,
|
||||||
|
card_last4 VARCHAR(4),
|
||||||
|
card_brand VARCHAR(20),
|
||||||
|
stripe_receipt_url VARCHAR,
|
||||||
|
|
||||||
-- Metadata
|
-- Metadata
|
||||||
notes TEXT,
|
notes TEXT,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
@@ -466,29 +551,10 @@ CREATE TABLE IF NOT EXISTS user_invitations (
|
|||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Import Jobs table
|
-- Add FK constraints to import_jobs (now that users table exists)
|
||||||
CREATE TABLE IF NOT EXISTS import_jobs (
|
ALTER TABLE import_jobs
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
ADD CONSTRAINT fk_import_jobs_rollback_by FOREIGN KEY (rollback_by) REFERENCES users(id),
|
||||||
|
ADD CONSTRAINT fk_import_jobs_started_by FOREIGN KEY (started_by) REFERENCES users(id);
|
||||||
filename VARCHAR NOT NULL,
|
|
||||||
status importjobstatus NOT NULL DEFAULT 'processing',
|
|
||||||
total_rows INTEGER DEFAULT 0,
|
|
||||||
processed_rows INTEGER DEFAULT 0,
|
|
||||||
success_count INTEGER DEFAULT 0,
|
|
||||||
error_count INTEGER DEFAULT 0,
|
|
||||||
error_log JSONB DEFAULT '[]'::jsonb,
|
|
||||||
|
|
||||||
-- WordPress import enhancements
|
|
||||||
field_mapping JSONB DEFAULT '{}'::jsonb,
|
|
||||||
wordpress_metadata JSONB DEFAULT '{}'::jsonb,
|
|
||||||
imported_user_ids JSONB DEFAULT '[]'::jsonb,
|
|
||||||
rollback_at TIMESTAMP WITH TIME ZONE,
|
|
||||||
rollback_by UUID REFERENCES users(id),
|
|
||||||
|
|
||||||
started_by UUID REFERENCES users(id),
|
|
||||||
started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
completed_at TIMESTAMP WITH TIME ZONE
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Import Rollback Audit table (for tracking rollback operations)
|
-- Import Rollback Audit table (for tracking rollback operations)
|
||||||
CREATE TABLE IF NOT EXISTS import_rollback_audit (
|
CREATE TABLE IF NOT EXISTS import_rollback_audit (
|
||||||
@@ -542,12 +608,18 @@ CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id);
|
|||||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_plan_id ON subscriptions(plan_id);
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_plan_id ON subscriptions(plan_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id);
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_payment_intent ON subscriptions(stripe_payment_intent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_charge_id ON subscriptions(stripe_charge_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_subscriptions_invoice_id ON subscriptions(stripe_invoice_id);
|
||||||
|
|
||||||
-- Donations indexes
|
-- Donations indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_donation_user ON donations(user_id);
|
CREATE INDEX IF NOT EXISTS idx_donation_user ON donations(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_donation_type ON donations(donation_type);
|
CREATE INDEX IF NOT EXISTS idx_donation_type ON donations(donation_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_donation_status ON donations(status);
|
CREATE INDEX IF NOT EXISTS idx_donation_status ON donations(status);
|
||||||
CREATE INDEX IF NOT EXISTS idx_donation_created ON donations(created_at);
|
CREATE INDEX IF NOT EXISTS idx_donation_created ON donations(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_donation_payment_intent ON donations(stripe_payment_intent_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_donation_charge_id ON donations(stripe_charge_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_donation_customer_id ON donations(stripe_customer_id);
|
||||||
|
|
||||||
-- Import Jobs indexes
|
-- Import Jobs indexes
|
||||||
CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status);
|
CREATE INDEX IF NOT EXISTS idx_import_jobs_status ON import_jobs(status);
|
||||||
|
|||||||
19
models.py
19
models.py
@@ -238,6 +238,15 @@ class Subscription(Base):
|
|||||||
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
|
donation_cents = Column(Integer, default=0, nullable=False) # Additional donation amount
|
||||||
# Note: amount_paid_cents = base_subscription_cents + donation_cents
|
# Note: amount_paid_cents = base_subscription_cents + donation_cents
|
||||||
|
|
||||||
|
# Stripe transaction metadata (for validation and audit)
|
||||||
|
stripe_payment_intent_id = Column(String, nullable=True, index=True) # Initial payment transaction ID
|
||||||
|
stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference
|
||||||
|
stripe_invoice_id = Column(String, nullable=True, index=True) # Invoice reference
|
||||||
|
payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe
|
||||||
|
card_last4 = Column(String(4), nullable=True) # Last 4 digits of card
|
||||||
|
card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc.
|
||||||
|
stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt
|
||||||
|
|
||||||
# Manual payment fields
|
# Manual payment fields
|
||||||
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment
|
manual_payment = Column(Boolean, default=False, nullable=False) # Whether this was a manual offline payment
|
||||||
manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment
|
manual_payment_notes = Column(Text, nullable=True) # Admin notes about the payment
|
||||||
@@ -269,9 +278,17 @@ class Donation(Base):
|
|||||||
|
|
||||||
# Payment details
|
# Payment details
|
||||||
stripe_checkout_session_id = Column(String, nullable=True)
|
stripe_checkout_session_id = Column(String, nullable=True)
|
||||||
stripe_payment_intent_id = Column(String, nullable=True)
|
stripe_payment_intent_id = Column(String, nullable=True, index=True)
|
||||||
payment_method = Column(String, nullable=True) # card, bank_transfer, etc.
|
payment_method = Column(String, nullable=True) # card, bank_transfer, etc.
|
||||||
|
|
||||||
|
# Stripe transaction metadata (for validation and audit)
|
||||||
|
stripe_charge_id = Column(String, nullable=True, index=True) # Actual charge reference
|
||||||
|
stripe_customer_id = Column(String, nullable=True, index=True) # Customer ID if created
|
||||||
|
payment_completed_at = Column(DateTime(timezone=True), nullable=True) # Exact payment timestamp from Stripe
|
||||||
|
card_last4 = Column(String(4), nullable=True) # Last 4 digits of card
|
||||||
|
card_brand = Column(String(20), nullable=True) # Visa, Mastercard, etc.
|
||||||
|
stripe_receipt_url = Column(String, nullable=True) # Link to Stripe receipt
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
notes = Column(Text, nullable=True)
|
notes = Column(Text, nullable=True)
|
||||||
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc))
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ motor==3.3.1
|
|||||||
msal==1.27.0
|
msal==1.27.0
|
||||||
mypy==1.18.2
|
mypy==1.18.2
|
||||||
mypy_extensions==1.1.0
|
mypy_extensions==1.1.0
|
||||||
numpy==2.2.6
|
numpy==2.3.5
|
||||||
oauthlib==3.3.1
|
oauthlib==3.3.1
|
||||||
packaging==25.0
|
packaging==25.0
|
||||||
pandas==2.3.3
|
pandas==2.3.3
|
||||||
|
|||||||
@@ -118,6 +118,40 @@ PERMISSIONS = [
|
|||||||
{"code": "permissions.audit", "name": "View Permission Audit Log", "description": "View permission change audit logs", "module": "permissions"},
|
{"code": "permissions.audit", "name": "View Permission Audit Log", "description": "View permission change audit logs", "module": "permissions"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Default system roles that must exist
|
||||||
|
DEFAULT_ROLES = [
|
||||||
|
{
|
||||||
|
"code": "guest",
|
||||||
|
"name": "Guest",
|
||||||
|
"description": "Default role for new registrations with no special permissions",
|
||||||
|
"is_system_role": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "member",
|
||||||
|
"name": "Member",
|
||||||
|
"description": "Active paying members with access to member-only content",
|
||||||
|
"is_system_role": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "finance",
|
||||||
|
"name": "Finance",
|
||||||
|
"description": "Financial management role with access to payments, subscriptions, and reports",
|
||||||
|
"is_system_role": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "admin",
|
||||||
|
"name": "Admin",
|
||||||
|
"description": "Board members with full management access except RBAC",
|
||||||
|
"is_system_role": True
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "superadmin",
|
||||||
|
"name": "Superadmin",
|
||||||
|
"description": "Full system access including RBAC management",
|
||||||
|
"is_system_role": True
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
# Default permission assignments for dynamic roles
|
# Default permission assignments for dynamic roles
|
||||||
DEFAULT_ROLE_PERMISSIONS = {
|
DEFAULT_ROLE_PERMISSIONS = {
|
||||||
"guest": [], # Guests have no permissions
|
"guest": [], # Guests have no permissions
|
||||||
@@ -196,7 +230,34 @@ def seed_permissions():
|
|||||||
print(f"\n⚠️ WARNING: Tables not fully cleared! Stopping.")
|
print(f"\n⚠️ WARNING: Tables not fully cleared! Stopping.")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Step 2: Create permissions
|
# Step 2: Create default system roles
|
||||||
|
print(f"\n👤 Creating {len(DEFAULT_ROLES)} system roles...")
|
||||||
|
role_map = {}
|
||||||
|
|
||||||
|
for role_data in DEFAULT_ROLES:
|
||||||
|
# Check if role already exists
|
||||||
|
existing_role = db.query(Role).filter(Role.code == role_data["code"]).first()
|
||||||
|
if existing_role:
|
||||||
|
print(f" • {role_data['name']}: Already exists, updating...")
|
||||||
|
existing_role.name = role_data["name"]
|
||||||
|
existing_role.description = role_data["description"]
|
||||||
|
existing_role.is_system_role = role_data["is_system_role"]
|
||||||
|
role_map[role_data["code"]] = existing_role
|
||||||
|
else:
|
||||||
|
print(f" • {role_data['name']}: Creating...")
|
||||||
|
role = Role(
|
||||||
|
code=role_data["code"],
|
||||||
|
name=role_data["name"],
|
||||||
|
description=role_data["description"],
|
||||||
|
is_system_role=role_data["is_system_role"]
|
||||||
|
)
|
||||||
|
db.add(role)
|
||||||
|
role_map[role_data["code"]] = role
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"✓ Created/updated {len(DEFAULT_ROLES)} system roles")
|
||||||
|
|
||||||
|
# Step 3: Create permissions
|
||||||
print(f"\n📝 Creating {len(PERMISSIONS)} permissions...")
|
print(f"\n📝 Creating {len(PERMISSIONS)} permissions...")
|
||||||
permission_map = {} # Map code to permission object
|
permission_map = {} # Map code to permission object
|
||||||
|
|
||||||
@@ -213,13 +274,13 @@ def seed_permissions():
|
|||||||
db.commit()
|
db.commit()
|
||||||
print(f"✓ Created {len(PERMISSIONS)} permissions")
|
print(f"✓ Created {len(PERMISSIONS)} permissions")
|
||||||
|
|
||||||
# Step 3: Get all roles from database
|
# Step 4: Verify roles exist
|
||||||
print("\n🔍 Fetching dynamic roles...")
|
print("\n🔍 Verifying dynamic roles...")
|
||||||
roles = db.query(Role).all()
|
roles = db.query(Role).all()
|
||||||
role_map = {role.code: role for role in roles}
|
role_map = {role.code: role for role in roles}
|
||||||
print(f"✓ Found {len(roles)} roles: {', '.join(role_map.keys())}")
|
print(f"✓ Found {len(roles)} roles: {', '.join(role_map.keys())}")
|
||||||
|
|
||||||
# Step 4: Assign permissions to roles
|
# Step 5: Assign permissions to roles
|
||||||
print("\n🔐 Assigning permissions to roles...")
|
print("\n🔐 Assigning permissions to roles...")
|
||||||
|
|
||||||
from models import UserRole # Import for enum mapping
|
from models import UserRole # Import for enum mapping
|
||||||
@@ -258,7 +319,7 @@ def seed_permissions():
|
|||||||
db.commit()
|
db.commit()
|
||||||
print(f" ✓ {role.name}: Assigned {len(permission_codes)} permissions")
|
print(f" ✓ {role.name}: Assigned {len(permission_codes)} permissions")
|
||||||
|
|
||||||
# Step 5: Summary
|
# Step 6: Summary
|
||||||
print("\n" + "=" * 80)
|
print("\n" + "=" * 80)
|
||||||
print("📊 SEEDING SUMMARY")
|
print("📊 SEEDING SUMMARY")
|
||||||
print("=" * 80)
|
print("=" * 80)
|
||||||
@@ -273,7 +334,8 @@ def seed_permissions():
|
|||||||
for module, count in sorted(modules.items()):
|
for module, count in sorted(modules.items()):
|
||||||
print(f" • {module.capitalize()}: {count} permissions")
|
print(f" • {module.capitalize()}: {count} permissions")
|
||||||
|
|
||||||
print(f"\nTotal permissions created: {len(PERMISSIONS)}")
|
print(f"\nTotal system roles created: {len(DEFAULT_ROLES)}")
|
||||||
|
print(f"Total permissions created: {len(PERMISSIONS)}")
|
||||||
print(f"Total role-permission mappings: {total_assigned}")
|
print(f"Total role-permission mappings: {total_assigned}")
|
||||||
print("\n✅ Permission seeding completed successfully!")
|
print("\n✅ Permission seeding completed successfully!")
|
||||||
print("\nNext step: Restart backend server")
|
print("\nNext step: Restart backend server")
|
||||||
|
|||||||
399
server.py
399
server.py
@@ -97,6 +97,15 @@ logging.basicConfig(
|
|||||||
)
|
)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Health Check Endpoint (for Kubernetes probes)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health_check():
|
||||||
|
"""Health check endpoint for Kubernetes liveness/readiness probes."""
|
||||||
|
return {"status": "healthy", "service": "membership-backend"}
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -227,6 +236,7 @@ class UserResponse(BaseModel):
|
|||||||
role: str
|
role: str
|
||||||
email_verified: bool
|
email_verified: bool
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
member_since: Optional[datetime] = None # Date when user became active member
|
||||||
# Profile
|
# Profile
|
||||||
profile_photo_url: Optional[str] = None
|
profile_photo_url: Optional[str] = None
|
||||||
# Subscription info (optional)
|
# Subscription info (optional)
|
||||||
@@ -482,6 +492,31 @@ class InviteUserRequest(BaseModel):
|
|||||||
last_name: Optional[str] = None
|
last_name: Optional[str] = None
|
||||||
phone: Optional[str] = None
|
phone: Optional[str] = None
|
||||||
|
|
||||||
|
class AdminUpdateUserRequest(BaseModel):
|
||||||
|
"""Admin-only endpoint for updating user profile fields"""
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
phone: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
city: Optional[str] = None
|
||||||
|
state: Optional[str] = None
|
||||||
|
zipcode: Optional[str] = None
|
||||||
|
date_of_birth: Optional[datetime] = None
|
||||||
|
member_since: Optional[datetime] = None
|
||||||
|
# Partner information
|
||||||
|
partner_first_name: Optional[str] = None
|
||||||
|
partner_last_name: Optional[str] = None
|
||||||
|
partner_is_member: Optional[bool] = None
|
||||||
|
partner_plan_to_become_member: Optional[bool] = None
|
||||||
|
referred_by_member_name: Optional[str] = None
|
||||||
|
|
||||||
|
@validator('date_of_birth', 'member_since', pre=True)
|
||||||
|
def empty_str_to_none(cls, v):
|
||||||
|
"""Convert empty string to None for optional datetime fields"""
|
||||||
|
if v == '' or v is None:
|
||||||
|
return None
|
||||||
|
return v
|
||||||
|
|
||||||
class InvitationResponse(BaseModel):
|
class InvitationResponse(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
email: str
|
email: str
|
||||||
@@ -1716,6 +1751,75 @@ async def get_my_event_activity(
|
|||||||
"total_rsvps": len(rsvps)
|
"total_rsvps": len(rsvps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Member Transaction History Endpoint
|
||||||
|
# ============================================================================
|
||||||
|
@api_router.get("/members/transactions")
|
||||||
|
async def get_member_transactions(
|
||||||
|
current_user: User = Depends(get_active_member),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get current member's transaction history including subscriptions and donations.
|
||||||
|
Returns both types of transactions sorted by date (newest first).
|
||||||
|
"""
|
||||||
|
# Get user's subscriptions with plan details
|
||||||
|
subscriptions = db.query(Subscription).filter(
|
||||||
|
Subscription.user_id == current_user.id
|
||||||
|
).order_by(Subscription.created_at.desc()).all()
|
||||||
|
|
||||||
|
subscription_list = []
|
||||||
|
for sub in subscriptions:
|
||||||
|
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == sub.plan_id).first()
|
||||||
|
subscription_list.append({
|
||||||
|
"id": str(sub.id),
|
||||||
|
"type": "subscription",
|
||||||
|
"description": plan.name if plan else "Subscription",
|
||||||
|
"amount_cents": sub.amount_paid_cents or (sub.base_subscription_cents + sub.donation_cents),
|
||||||
|
"base_amount_cents": sub.base_subscription_cents,
|
||||||
|
"donation_cents": sub.donation_cents,
|
||||||
|
"status": sub.status.value if sub.status else "unknown",
|
||||||
|
"payment_method": sub.payment_method,
|
||||||
|
"card_brand": sub.card_brand,
|
||||||
|
"card_last4": sub.card_last4,
|
||||||
|
"stripe_receipt_url": sub.stripe_receipt_url,
|
||||||
|
"created_at": sub.created_at.isoformat() if sub.created_at else None,
|
||||||
|
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
|
||||||
|
"start_date": sub.start_date.isoformat() if sub.start_date else None,
|
||||||
|
"end_date": sub.end_date.isoformat() if sub.end_date else None,
|
||||||
|
"billing_cycle": plan.billing_cycle if plan else None,
|
||||||
|
"manual_payment": sub.manual_payment
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get user's donations
|
||||||
|
donations = db.query(Donation).filter(
|
||||||
|
Donation.user_id == current_user.id
|
||||||
|
).order_by(Donation.created_at.desc()).all()
|
||||||
|
|
||||||
|
donation_list = []
|
||||||
|
for don in donations:
|
||||||
|
donation_list.append({
|
||||||
|
"id": str(don.id),
|
||||||
|
"type": "donation",
|
||||||
|
"description": "Donation",
|
||||||
|
"amount_cents": don.amount_cents,
|
||||||
|
"status": don.status.value if don.status else "unknown",
|
||||||
|
"payment_method": don.payment_method,
|
||||||
|
"card_brand": don.card_brand,
|
||||||
|
"card_last4": don.card_last4,
|
||||||
|
"stripe_receipt_url": don.stripe_receipt_url,
|
||||||
|
"created_at": don.created_at.isoformat() if don.created_at else None,
|
||||||
|
"payment_completed_at": don.payment_completed_at.isoformat() if don.payment_completed_at else None,
|
||||||
|
"notes": don.notes
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"subscriptions": subscription_list,
|
||||||
|
"donations": donation_list,
|
||||||
|
"total_subscription_amount_cents": sum(s["amount_cents"] or 0 for s in subscription_list),
|
||||||
|
"total_donation_amount_cents": sum(d["amount_cents"] or 0 for d in donation_list)
|
||||||
|
}
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Calendar Export Endpoints (Universal iCalendar .ics format)
|
# Calendar Export Endpoints (Universal iCalendar .ics format)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -2274,10 +2378,143 @@ async def get_user_by_id(
|
|||||||
"email_verified": user.email_verified,
|
"email_verified": user.email_verified,
|
||||||
"newsletter_subscribed": user.newsletter_subscribed,
|
"newsletter_subscribed": user.newsletter_subscribed,
|
||||||
"lead_sources": user.lead_sources,
|
"lead_sources": user.lead_sources,
|
||||||
|
"member_since": user.member_since.isoformat() if user.member_since else None,
|
||||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||||
"updated_at": user.updated_at.isoformat() if user.updated_at else None
|
"updated_at": user.updated_at.isoformat() if user.updated_at else None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@api_router.get("/admin/users/{user_id}/transactions")
|
||||||
|
async def get_user_transactions(
|
||||||
|
user_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("users.view"))
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a specific user's transaction history (admin only).
|
||||||
|
Returns subscriptions and donations for the specified user.
|
||||||
|
"""
|
||||||
|
# Verify user exists
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Get user's subscriptions with plan details
|
||||||
|
subscriptions = db.query(Subscription).filter(
|
||||||
|
Subscription.user_id == user_id
|
||||||
|
).order_by(Subscription.created_at.desc()).all()
|
||||||
|
|
||||||
|
subscription_list = []
|
||||||
|
for sub in subscriptions:
|
||||||
|
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == sub.plan_id).first()
|
||||||
|
subscription_list.append({
|
||||||
|
"id": str(sub.id),
|
||||||
|
"type": "subscription",
|
||||||
|
"description": plan.name if plan else "Subscription",
|
||||||
|
"amount_cents": sub.amount_paid_cents or (sub.base_subscription_cents + sub.donation_cents),
|
||||||
|
"base_amount_cents": sub.base_subscription_cents,
|
||||||
|
"donation_cents": sub.donation_cents,
|
||||||
|
"status": sub.status.value if sub.status else "unknown",
|
||||||
|
"payment_method": sub.payment_method,
|
||||||
|
"card_brand": sub.card_brand,
|
||||||
|
"card_last4": sub.card_last4,
|
||||||
|
"stripe_receipt_url": sub.stripe_receipt_url,
|
||||||
|
"created_at": sub.created_at.isoformat() if sub.created_at else None,
|
||||||
|
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
|
||||||
|
"start_date": sub.start_date.isoformat() if sub.start_date else None,
|
||||||
|
"end_date": sub.end_date.isoformat() if sub.end_date else None,
|
||||||
|
"billing_cycle": plan.billing_cycle if plan else None,
|
||||||
|
"manual_payment": sub.manual_payment,
|
||||||
|
"manual_payment_notes": sub.manual_payment_notes
|
||||||
|
})
|
||||||
|
|
||||||
|
# Get user's donations
|
||||||
|
donations = db.query(Donation).filter(
|
||||||
|
Donation.user_id == user_id
|
||||||
|
).order_by(Donation.created_at.desc()).all()
|
||||||
|
|
||||||
|
donation_list = []
|
||||||
|
for don in donations:
|
||||||
|
donation_list.append({
|
||||||
|
"id": str(don.id),
|
||||||
|
"type": "donation",
|
||||||
|
"description": "Donation",
|
||||||
|
"amount_cents": don.amount_cents,
|
||||||
|
"status": don.status.value if don.status else "unknown",
|
||||||
|
"payment_method": don.payment_method,
|
||||||
|
"card_brand": don.card_brand,
|
||||||
|
"card_last4": don.card_last4,
|
||||||
|
"stripe_receipt_url": don.stripe_receipt_url,
|
||||||
|
"created_at": don.created_at.isoformat() if don.created_at else None,
|
||||||
|
"payment_completed_at": don.payment_completed_at.isoformat() if don.payment_completed_at else None,
|
||||||
|
"notes": don.notes
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"user_id": str(user.id),
|
||||||
|
"user_name": f"{user.first_name} {user.last_name}",
|
||||||
|
"subscriptions": subscription_list,
|
||||||
|
"donations": donation_list,
|
||||||
|
"total_subscription_amount_cents": sum(s["amount_cents"] or 0 for s in subscription_list),
|
||||||
|
"total_donation_amount_cents": sum(d["amount_cents"] or 0 for d in donation_list)
|
||||||
|
}
|
||||||
|
|
||||||
|
@api_router.put("/admin/users/{user_id}")
|
||||||
|
async def update_user_profile(
|
||||||
|
user_id: str,
|
||||||
|
request: AdminUpdateUserRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("users.edit"))
|
||||||
|
):
|
||||||
|
"""Update user profile fields (admin only)"""
|
||||||
|
user = db.query(User).filter(User.id == user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Update basic personal information
|
||||||
|
if request.first_name is not None:
|
||||||
|
user.first_name = request.first_name
|
||||||
|
if request.last_name is not None:
|
||||||
|
user.last_name = request.last_name
|
||||||
|
if request.phone is not None:
|
||||||
|
user.phone = request.phone
|
||||||
|
if request.address is not None:
|
||||||
|
user.address = request.address
|
||||||
|
if request.city is not None:
|
||||||
|
user.city = request.city
|
||||||
|
if request.state is not None:
|
||||||
|
user.state = request.state
|
||||||
|
if request.zipcode is not None:
|
||||||
|
user.zipcode = request.zipcode
|
||||||
|
if request.date_of_birth is not None:
|
||||||
|
user.date_of_birth = request.date_of_birth
|
||||||
|
|
||||||
|
# Update member_since (admin only)
|
||||||
|
if request.member_since is not None:
|
||||||
|
user.member_since = request.member_since
|
||||||
|
|
||||||
|
# Update partner information
|
||||||
|
if request.partner_first_name is not None:
|
||||||
|
user.partner_first_name = request.partner_first_name
|
||||||
|
if request.partner_last_name is not None:
|
||||||
|
user.partner_last_name = request.partner_last_name
|
||||||
|
if request.partner_is_member is not None:
|
||||||
|
user.partner_is_member = request.partner_is_member
|
||||||
|
if request.partner_plan_to_become_member is not None:
|
||||||
|
user.partner_plan_to_become_member = request.partner_plan_to_become_member
|
||||||
|
if request.referred_by_member_name is not None:
|
||||||
|
user.referred_by_member_name = request.referred_by_member_name
|
||||||
|
|
||||||
|
user.updated_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(user)
|
||||||
|
|
||||||
|
logger.info(f"Admin {current_user.email} updated profile for user {user.email}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "User profile updated successfully",
|
||||||
|
"user_id": str(user.id)
|
||||||
|
}
|
||||||
|
|
||||||
@api_router.put("/admin/users/{user_id}/validate")
|
@api_router.put("/admin/users/{user_id}/validate")
|
||||||
async def validate_user(
|
async def validate_user(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -2476,6 +2713,9 @@ async def activate_payment_manually(
|
|||||||
# 6. Activate user
|
# 6. Activate user
|
||||||
user.status = UserStatus.active
|
user.status = UserStatus.active
|
||||||
set_user_role(user, UserRole.member, db)
|
set_user_role(user, UserRole.member, db)
|
||||||
|
# Set member_since only if not already set (first time activation)
|
||||||
|
if not user.member_since:
|
||||||
|
user.member_since = datetime.now(timezone.utc)
|
||||||
user.updated_at = datetime.now(timezone.utc)
|
user.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
# 7. Commit
|
# 7. Commit
|
||||||
@@ -4525,8 +4765,17 @@ async def get_all_subscriptions(
|
|||||||
"donation_cents": sub.donation_cents,
|
"donation_cents": sub.donation_cents,
|
||||||
"payment_method": sub.payment_method,
|
"payment_method": sub.payment_method,
|
||||||
"stripe_subscription_id": sub.stripe_subscription_id,
|
"stripe_subscription_id": sub.stripe_subscription_id,
|
||||||
|
"stripe_customer_id": sub.stripe_customer_id,
|
||||||
"created_at": sub.created_at,
|
"created_at": sub.created_at,
|
||||||
"updated_at": sub.updated_at
|
"updated_at": sub.updated_at,
|
||||||
|
# Stripe transaction metadata
|
||||||
|
"stripe_payment_intent_id": sub.stripe_payment_intent_id,
|
||||||
|
"stripe_charge_id": sub.stripe_charge_id,
|
||||||
|
"stripe_invoice_id": sub.stripe_invoice_id,
|
||||||
|
"payment_completed_at": sub.payment_completed_at.isoformat() if sub.payment_completed_at else None,
|
||||||
|
"card_last4": sub.card_last4,
|
||||||
|
"card_brand": sub.card_brand,
|
||||||
|
"stripe_receipt_url": sub.stripe_receipt_url
|
||||||
} for sub in subscriptions]
|
} for sub in subscriptions]
|
||||||
|
|
||||||
@api_router.get("/admin/subscriptions/stats")
|
@api_router.get("/admin/subscriptions/stats")
|
||||||
@@ -4766,7 +5015,15 @@ async def get_donations(
|
|||||||
"donor_email": d.donor_email or (d.user.email if d.user else None),
|
"donor_email": d.donor_email or (d.user.email if d.user else None),
|
||||||
"payment_method": d.payment_method,
|
"payment_method": d.payment_method,
|
||||||
"notes": d.notes,
|
"notes": d.notes,
|
||||||
"created_at": d.created_at.isoformat()
|
"created_at": d.created_at.isoformat(),
|
||||||
|
# Stripe transaction metadata
|
||||||
|
"stripe_payment_intent_id": d.stripe_payment_intent_id,
|
||||||
|
"stripe_charge_id": d.stripe_charge_id,
|
||||||
|
"stripe_customer_id": d.stripe_customer_id,
|
||||||
|
"payment_completed_at": d.payment_completed_at.isoformat() if d.payment_completed_at else None,
|
||||||
|
"card_last4": d.card_last4,
|
||||||
|
"card_brand": d.card_brand,
|
||||||
|
"stripe_receipt_url": d.stripe_receipt_url
|
||||||
} for d in donations]
|
} for d in donations]
|
||||||
|
|
||||||
@api_router.get("/admin/donations/stats")
|
@api_router.get("/admin/donations/stats")
|
||||||
@@ -6080,7 +6337,15 @@ async def create_checkout(
|
|||||||
|
|
||||||
# Create Stripe Checkout Session
|
# Create Stripe Checkout Session
|
||||||
import stripe
|
import stripe
|
||||||
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
# Try to get Stripe API key from database first, then fall back to environment
|
||||||
|
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
|
||||||
|
if not stripe_key:
|
||||||
|
stripe_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
|
||||||
|
if not stripe_key:
|
||||||
|
raise HTTPException(status_code=500, detail="Stripe API key not configured")
|
||||||
|
|
||||||
|
stripe.api_key = stripe_key
|
||||||
|
|
||||||
mode = "subscription" if stripe_interval else "payment"
|
mode = "subscription" if stripe_interval else "payment"
|
||||||
|
|
||||||
@@ -6155,7 +6420,15 @@ async def create_donation_checkout(
|
|||||||
|
|
||||||
# Create Stripe Checkout Session for one-time payment
|
# Create Stripe Checkout Session for one-time payment
|
||||||
import stripe
|
import stripe
|
||||||
stripe.api_key = os.getenv("STRIPE_SECRET_KEY")
|
# Try to get Stripe API key from database first, then fall back to environment
|
||||||
|
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
|
||||||
|
if not stripe_key:
|
||||||
|
stripe_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
|
||||||
|
if not stripe_key:
|
||||||
|
raise HTTPException(status_code=500, detail="Stripe API key not configured")
|
||||||
|
|
||||||
|
stripe.api_key = stripe_key
|
||||||
|
|
||||||
checkout_session = stripe.checkout.Session.create(
|
checkout_session = stripe.checkout.Session.create(
|
||||||
payment_method_types=['card'],
|
payment_method_types=['card'],
|
||||||
@@ -6314,23 +6587,67 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
donation = db.query(Donation).filter(Donation.id == donation_id).first()
|
donation = db.query(Donation).filter(Donation.id == donation_id).first()
|
||||||
|
|
||||||
if donation:
|
if donation:
|
||||||
|
# Get Stripe API key from database
|
||||||
|
import stripe
|
||||||
|
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
|
||||||
|
if not stripe_key:
|
||||||
|
stripe_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
stripe.api_key = stripe_key
|
||||||
|
|
||||||
|
# Extract basic payment info
|
||||||
|
payment_intent_id = session.get('payment_intent')
|
||||||
donation.status = DonationStatus.completed
|
donation.status = DonationStatus.completed
|
||||||
donation.stripe_payment_intent_id = session.get('payment_intent')
|
donation.stripe_payment_intent_id = payment_intent_id
|
||||||
|
donation.stripe_customer_id = session.get('customer')
|
||||||
donation.payment_method = 'card'
|
donation.payment_method = 'card'
|
||||||
|
donation.payment_completed_at = datetime.fromtimestamp(session.get('created'), tz=timezone.utc)
|
||||||
|
|
||||||
|
# Capture donor email and name from Stripe session if not already set
|
||||||
|
if not donation.donor_email and session.get('customer_details'):
|
||||||
|
customer_details = session.get('customer_details')
|
||||||
|
donation.donor_email = customer_details.get('email')
|
||||||
|
if not donation.donor_name and customer_details.get('name'):
|
||||||
|
donation.donor_name = customer_details.get('name')
|
||||||
|
|
||||||
|
# Retrieve PaymentIntent to get charge details
|
||||||
|
try:
|
||||||
|
if payment_intent_id:
|
||||||
|
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
|
||||||
|
|
||||||
|
# Get charge ID from latest_charge
|
||||||
|
charge_id = payment_intent.latest_charge if hasattr(payment_intent, 'latest_charge') else None
|
||||||
|
|
||||||
|
if charge_id:
|
||||||
|
# Retrieve the charge to get full details
|
||||||
|
charge = stripe.Charge.retrieve(charge_id)
|
||||||
|
donation.stripe_charge_id = charge.id
|
||||||
|
donation.stripe_receipt_url = charge.receipt_url
|
||||||
|
|
||||||
|
# Get card details
|
||||||
|
if hasattr(charge, 'payment_method_details') and charge.payment_method_details and charge.payment_method_details.card:
|
||||||
|
card = charge.payment_method_details.card
|
||||||
|
donation.card_last4 = card.last4
|
||||||
|
donation.card_brand = card.brand.capitalize() # visa -> Visa
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve Stripe payment details for donation: {str(e)}")
|
||||||
|
|
||||||
donation.updated_at = datetime.now(timezone.utc)
|
donation.updated_at = datetime.now(timezone.utc)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Send thank you email
|
# Send thank you email only if donor_email exists
|
||||||
try:
|
if donation.donor_email:
|
||||||
from email_service import send_donation_thank_you_email
|
try:
|
||||||
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
|
from email_service import send_donation_thank_you_email
|
||||||
await send_donation_thank_you_email(
|
donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend"
|
||||||
donation.donor_email,
|
await send_donation_thank_you_email(
|
||||||
donor_first_name,
|
donation.donor_email,
|
||||||
donation.amount_cents
|
donor_first_name,
|
||||||
)
|
donation.amount_cents
|
||||||
except Exception as e:
|
)
|
||||||
logger.error(f"Failed to send donation thank you email: {str(e)}")
|
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})")
|
logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})")
|
||||||
else:
|
else:
|
||||||
@@ -6360,15 +6677,26 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
).first()
|
).first()
|
||||||
|
|
||||||
if not existing_subscription:
|
if not existing_subscription:
|
||||||
|
# Get Stripe API key from database
|
||||||
|
import stripe
|
||||||
|
stripe_key = get_setting(db, 'stripe_secret_key', decrypt=True)
|
||||||
|
if not stripe_key:
|
||||||
|
stripe_key = os.getenv("STRIPE_SECRET_KEY")
|
||||||
|
stripe.api_key = stripe_key
|
||||||
|
|
||||||
# Calculate subscription period using custom billing cycle if enabled
|
# Calculate subscription period using custom billing cycle if enabled
|
||||||
from payment_service import calculate_subscription_period
|
from payment_service import calculate_subscription_period
|
||||||
start_date, end_date = calculate_subscription_period(plan)
|
start_date, end_date = calculate_subscription_period(plan)
|
||||||
|
|
||||||
|
# Extract basic payment info
|
||||||
|
payment_intent_id = session.get('payment_intent')
|
||||||
|
subscription_id = session.get("subscription")
|
||||||
|
|
||||||
# Create subscription record with donation tracking
|
# Create subscription record with donation tracking
|
||||||
subscription = Subscription(
|
subscription = Subscription(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
plan_id=plan.id,
|
plan_id=plan.id,
|
||||||
stripe_subscription_id=session.get("subscription"),
|
stripe_subscription_id=subscription_id,
|
||||||
stripe_customer_id=session.get("customer"),
|
stripe_customer_id=session.get("customer"),
|
||||||
status=SubscriptionStatus.active,
|
status=SubscriptionStatus.active,
|
||||||
start_date=start_date,
|
start_date=start_date,
|
||||||
@@ -6376,13 +6704,48 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
|||||||
amount_paid_cents=total_amount,
|
amount_paid_cents=total_amount,
|
||||||
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
base_subscription_cents=base_amount or plan.minimum_price_cents,
|
||||||
donation_cents=donation_amount,
|
donation_cents=donation_amount,
|
||||||
payment_method="stripe"
|
payment_method="stripe",
|
||||||
|
stripe_payment_intent_id=payment_intent_id,
|
||||||
|
payment_completed_at=datetime.fromtimestamp(session.get('created'), tz=timezone.utc)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Retrieve PaymentIntent and Subscription to get detailed transaction info
|
||||||
|
try:
|
||||||
|
if payment_intent_id:
|
||||||
|
payment_intent = stripe.PaymentIntent.retrieve(payment_intent_id)
|
||||||
|
|
||||||
|
# Get charge ID from latest_charge
|
||||||
|
charge_id = payment_intent.latest_charge if hasattr(payment_intent, 'latest_charge') else None
|
||||||
|
|
||||||
|
if charge_id:
|
||||||
|
# Retrieve the charge to get full details
|
||||||
|
charge = stripe.Charge.retrieve(charge_id)
|
||||||
|
subscription.stripe_charge_id = charge.id
|
||||||
|
subscription.stripe_receipt_url = charge.receipt_url
|
||||||
|
|
||||||
|
# Get card details
|
||||||
|
if hasattr(charge, 'payment_method_details') and charge.payment_method_details and charge.payment_method_details.card:
|
||||||
|
card = charge.payment_method_details.card
|
||||||
|
subscription.card_last4 = card.last4
|
||||||
|
subscription.card_brand = card.brand.capitalize() # visa -> Visa
|
||||||
|
|
||||||
|
# Get invoice ID from subscription
|
||||||
|
if subscription_id:
|
||||||
|
stripe_subscription = stripe.Subscription.retrieve(subscription_id)
|
||||||
|
if hasattr(stripe_subscription, 'latest_invoice') and stripe_subscription.latest_invoice:
|
||||||
|
subscription.stripe_invoice_id = stripe_subscription.latest_invoice
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to retrieve Stripe payment details for subscription: {str(e)}")
|
||||||
|
|
||||||
db.add(subscription)
|
db.add(subscription)
|
||||||
|
|
||||||
# Update user status and role
|
# Update user status and role
|
||||||
user.status = UserStatus.active
|
user.status = UserStatus.active
|
||||||
set_user_role(user, UserRole.member, db)
|
set_user_role(user, UserRole.member, db)
|
||||||
|
# Set member_since only if not already set (first time activation)
|
||||||
|
if not user.member_since:
|
||||||
|
user.member_since = datetime.now(timezone.utc)
|
||||||
user.updated_at = datetime.now(timezone.utc)
|
user.updated_at = datetime.now(timezone.utc)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
Reference in New Issue
Block a user