Initial commit: WealthWise financial analytics platform

This commit is contained in:
2026-02-14 21:16:57 +05:30
commit b8588df583
171 changed files with 29048 additions and 0 deletions

56
backend/.env.example Normal file
View File

@@ -0,0 +1,56 @@
# ==========================================
# WealthWise Backend Environment Variables
# ==========================================
# Copy this file to .env and fill in your actual values
# DO NOT commit .env to version control!
# ==========================================
# Application Configuration
# ==========================================
PROJECT_NAME=WealthWise
API_V1_STR=/api/v1
VERSION=1.0.0
DEBUG=False
# ==========================================
# Database Configuration (Self-Hosted PostgreSQL)
# ==========================================
# For Docker Compose:
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/wealthwise
# For production (update with your actual values):
# DATABASE_URL=postgresql+asyncpg://user:password@your-db-host:5432/wealthwise
# Database Pool Configuration
DB_POOL_SIZE=20
DB_MAX_OVERFLOW=10
DB_POOL_PRE_PING=True
DB_ECHO=False
# ==========================================
# Security & Authentication
# ==========================================
# CRITICAL: Generate a strong secret key for JWT signing in production!
# Use: openssl rand -hex 32
SECRET_KEY=your-super-secret-key-change-this-in-production
# JWT Configuration
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# ==========================================
# CORS Configuration
# ==========================================
# Comma-separated list of allowed origins
# For development, include your frontend dev server
CORS_ORIGINS=http://localhost:5173,http://localhost:3000,http://127.0.0.1:5173
# ==========================================
# Logging
# ==========================================
LOG_LEVEL=INFO
# ==========================================
# Optional: Monitoring (Future)
# ==========================================
# SENTRY_DSN=

106
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,106 @@
# ==========================================
# Python / Poetry
# ==========================================
# Virtual environments
venv/
.venv/
env/
ENV/
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Distribution / packaging
build/
dist/
*.egg-info/
.eggs/
# PyInstaller
*.manifest
*.spec
# Unit test / coverage
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
.hypothesis/
.pytest_cache/
# ==========================================
# Environment Variables
# ==========================================
.env
.env.local
.env.*.local
.env.production
.env.staging
# ==========================================
# IDEs and Editors
# ==========================================
# VS Code
.vscode/
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# PyCharm / IntelliJ
.idea/
*.iml
*.iws
*.ipr
# Sublime Text
*.sublime-project
*.sublime-workspace
# Emacs
*~
\#*\#
# Vim
*.swp
*.swo
# macOS
.DS_Store
.AppleDouble
.LSOverride
# ==========================================
# Logs and Databases
# ==========================================
*.log
*.sql
*.sqlite
*.sqlite3
*.db
# ==========================================
# Application Specific
# ==========================================
# Local development files
local_settings.py
# Alembic migrations (if using)
# Uncomment if you want to ignore migrations
# alembic/versions/*
# !alembic/versions/.gitkeep
# Temporary files
tmp/
temp/
*.tmp

164
backend/ALEMBIC.md Normal file
View File

@@ -0,0 +1,164 @@
# Database Migrations with Alembic
This guide explains how to set up and run database migrations using Alembic.
## Prerequisites
1. Database must be running (use Docker Compose)
2. Alembic must be installed: `poetry install` (already included in pyproject.toml)
## Quick Start
### 1. Start the Database
```bash
cd backend
docker-compose up -d db
```
### 2. Initialize Alembic (First Time Only)
```bash
# Install alembic if not already installed
poetry add alembic
# Initialize alembic
poetry run alembic init alembic
```
### 3. Configure Alembic
Update `alembic.ini`:
```ini
sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/wealthwise
```
Update `alembic/env.py`:
```python
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine
# Import your models
from app.models import User, Portfolio, Transaction
from app.models.base import BaseModel
# Set target metadata
target_metadata = BaseModel.metadata
def do_run_migrations(connection):
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online():
"""Run migrations in 'online' mode with async engine."""
configuration = config.get_section(config.config_ini_section)
# Convert sync URL to async URL
url = configuration['sqlalchemy.url'].replace(
'postgresql://', 'postgresql+asyncpg://'
)
connectable = create_async_engine(url)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
```
### 4. Create Initial Migration
```bash
# Generate migration script
poetry run alembic revision --autogenerate -m "Initial migration with user, portfolio, transaction"
# Apply migration
poetry run alembic upgrade head
```
## Common Commands
### Create a New Migration
```bash
# Auto-generate from model changes
poetry run alembic revision --autogenerate -m "Add new field to user"
# Create empty migration
poetry run alembic revision -m "Manual migration"
```
### Apply Migrations
```bash
# Upgrade to latest
poetry run alembic upgrade head
# Upgrade specific number of revisions
poetry run alembic upgrade +2
# Upgrade to specific revision
poetry run alembic upgrade abc123
```
### Rollback Migrations
```bash
# Downgrade one revision
poetry run alembic downgrade -1
# Downgrade to specific revision
poetry run alembic downgrade abc123
# Downgrade all the way
poetry run alembic downgrade base
```
### View Migration Status
```bash
# Current revision
poetry run alembic current
# History
poetry run alembic history --verbose
```
## Migration Best Practices
1. **Always review auto-generated migrations** before applying them
2. **Test migrations** on a copy of production data before running in production
3. **Never modify** existing migration files after they've been applied
4. **Use descriptive names** for migration messages
5. **One logical change per migration** (don't bundle unrelated changes)
## Troubleshooting
### Async Issues
If you encounter async-related errors, ensure you're using `create_async_engine` and `AsyncEngine` in `env.py`.
### Import Errors
Make sure all models are imported in `env.py`:
```python
from app.models import User, Portfolio, Transaction
```
### Connection Issues
Ensure the database is running and accessible:
```bash
docker-compose ps
# or
pg_isready -h localhost -p 5432 -U postgres
```

71
backend/Dockerfile Normal file
View File

@@ -0,0 +1,71 @@
# Multi-stage Dockerfile for WealthWise Backend
# Stage 1: Builder - Install dependencies and export requirements
# Stage 2: Runtime - Slim production image
# ==========================================
# STAGE 1: Builder
# ==========================================
FROM python:3.11-slim as builder
WORKDIR /build
# Install system dependencies for building Python packages
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
# Install Poetry
RUN pip install --no-cache-dir poetry==1.7.1
# Copy Poetry configuration
COPY pyproject.toml ./
# Configure Poetry to not create a virtual environment
# and export dependencies to requirements.txt
RUN poetry config virtualenvs.create false && \
poetry export -f requirements.txt --output requirements.txt --without-hashes --only main
# ==========================================
# STAGE 2: Runtime
# ==========================================
FROM python:3.11-slim as runtime
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Create non-root user for security
RUN useradd --create-home --shell /bin/bash app && \
chown -R app:app /app
USER app
# Copy requirements from builder stage
COPY --from=builder /build/requirements.txt .
# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY --chown=app:app app/ ./app/
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health')" || exit 1
# Run the application with uvicorn
# Using multiple workers for production (adjust based on CPU cores)
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4"]

270
backend/README.md Normal file
View File

@@ -0,0 +1,270 @@
# WealthWise Backend
Production-grade FastAPI backend for WealthWise financial analytics platform with self-hosted authentication.
## Quick Start
### Prerequisites
- Python 3.11+
- Poetry 1.7+
- Docker & Docker Compose (recommended)
- PostgreSQL 15+
### Option 1: Docker Compose (Recommended)
The easiest way to get started with both database and backend:
```bash
cd backend
# Copy environment template
cp .env.example .env
# Start all services (database + backend)
docker-compose up -d
# View logs
docker-compose logs -f backend
# Stop services
docker-compose down
```
The API will be available at:
- API Docs: http://localhost:8000/api/v1/docs
- Health Check: http://localhost:8000/api/v1/health
### Option 2: Local Development with Docker Database
1. **Start the database:**
```bash
docker-compose up -d db
```
2. **Install dependencies:**
```bash
poetry install
```
3. **Set up environment:**
```bash
cp .env.example .env
# Edit .env if needed (default should work with Docker DB)
```
4. **Run database migrations:**
```bash
# See ALEMBIC.md for detailed instructions
poetry run alembic upgrade head
```
5. **Run the server:**
```bash
poetry run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
```
## Authentication System
WealthWise uses self-hosted JWT-based authentication with OAuth2 Password Flow.
### Registration
```bash
curl -X POST http://localhost:8000/api/v1/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "user@wealthwise.app",
"password": "securepassword123"
}'
```
### Login
```bash
curl -X POST http://localhost:8000/api/v1/auth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "username=user@wealthwise.app&password=securepassword123"
```
Response:
```json
{
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "bearer"
}
```
### Access Protected Endpoint
```bash
curl -X GET http://localhost:8000/api/v1/users/me \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
```
### Using Swagger UI
1. Open http://localhost:8000/api/v1/docs
2. Click "Authorize" button (top right)
3. Enter: `Bearer <your-token>` (replace with actual token)
4. Or use the `/auth/token` endpoint to get a token, then use it
## Testing
```bash
# Run all tests
poetry run pytest
# Run with coverage
poetry run pytest --cov=app --cov-report=html
# Run specific test file
poetry run pytest tests/test_auth.py -v
# Run auth tests only
poetry run pytest tests/test_auth.py -v
```
## Database Migrations
See [ALEMBIC.md](ALEMBIC.md) for detailed migration instructions.
Quick commands:
```bash
# Create migration
poetry run alembic revision --autogenerate -m "Description"
# Apply migrations
poetry run alembic upgrade head
# View history
poetry run alembic history
```
## Project Structure
```
backend/
├── app/
│ ├── api/
│ │ ├── v1/
│ │ │ ├── endpoints/
│ │ │ │ ├── auth.py # Authentication endpoints
│ │ │ │ ├── health.py # Health check endpoint
│ │ │ │ └── users.py # User endpoints
│ │ │ └── api.py # Router aggregation
│ │ └── deps.py # Dependencies (DB session, auth)
│ ├── core/
│ │ ├── config.py # Settings management
│ │ ├── db.py # Database engine & sessions
│ │ └── security.py # Password hashing & JWT utilities
│ ├── models/
│ │ ├── __init__.py # Model exports
│ │ ├── base.py # Base SQLModel classes
│ │ ├── user.py # User model
│ │ ├── portfolio.py # Portfolio model
│ │ └── transaction.py # Transaction model
│ ├── schemas/
│ │ ├── __init__.py # Schema exports
│ │ ├── token.py # Token schemas (legacy Supabase)
│ │ └── user.py # User schemas
│ └── main.py # FastAPI application factory
├── tests/ # Test suite
├── pyproject.toml # Poetry configuration
├── Dockerfile # Multi-stage build
├── docker-compose.yml # Docker Compose services
├── .env.example # Environment template
├── README.md # This file
└── ALEMBIC.md # Migration guide
```
## Environment Variables
See `.env.example` for all available configuration options.
### Required Variables
- `DATABASE_URL`: PostgreSQL connection string
- Local Docker: `postgresql+asyncpg://postgres:postgres@localhost:5432/wealthwise`
- Production: `postgresql+asyncpg://user:pass@host:5432/dbname`
- `SECRET_KEY`: JWT signing secret (generate with `openssl rand -hex 32`)
- `ALGORITHM`: JWT algorithm (default: HS256)
- `ACCESS_TOKEN_EXPIRE_MINUTES`: Token expiration (default: 30)
### Docker Compose Configuration
The docker-compose.yml sets up:
- PostgreSQL 15 with persistent volume
- FastAPI backend with hot reload
- Health checks for both services
- Shared network for service communication
## API Endpoints
### Authentication
- `POST /api/v1/auth/register` - Register new user
- `POST /api/v1/auth/token` - Login (OAuth2 Password Flow)
### Users (Protected)
- `GET /api/v1/users/me` - Get current user info
- `GET /api/v1/users/me/active` - Get current active user
### Health Check
- `GET /api/v1/health` - Comprehensive health check with DB connectivity
- `GET /api/v1/health/ready` - Kubernetes readiness probe
- `GET /api/v1/health/live` - Kubernetes liveness probe
### Documentation
- `GET /api/v1/docs` - Swagger UI
- `GET /api/v1/redoc` - ReDoc UI
- `GET /api/v1/openapi.json` - OpenAPI specification
## Development Guidelines
### Adding New Endpoints
1. Create endpoint file in `app/api/v1/endpoints/`
2. Add router to `app/api/v1/api.py`
3. Write tests in `tests/`
### Database Models
1. Inherit from `BaseModel` in `app/models/base.py`
2. Use SQLModel for type-safe ORM operations
3. Create migration after model changes: `poetry run alembic revision --autogenerate -m "Description"`
### Authentication in Endpoints
```python
from fastapi import APIRouter, Depends
from app.api.deps import get_current_user
from app.models import User
router = APIRouter()
@router.get("/protected")
async def protected_route(current_user: User = Depends(get_current_user)):
return {"message": f"Hello {current_user.email}"}
```
### Code Quality
- Format with Black: `poetry run black app tests`
- Import sort with isort: `poetry run isort app tests`
- Type check with mypy: `poetry run mypy app`
## Security Notes
- **Passwords**: Hashed using bcrypt (12 rounds by default)
- **JWT Tokens**: HS256 algorithm, 30-minute expiration by default
- **SECRET_KEY**: Must be changed in production! Use a strong random key.
- **HTTPS**: Always use HTTPS in production to protect tokens
- **CORS**: Configure CORS_ORIGINS to only allow your frontend domain
## Migration from Supabase
This backend has been migrated from Supabase Auth to self-hosted authentication:
- ✅ JWT validation uses local `SECRET_KEY` instead of Supabase
- ✅ Password hashing with bcrypt
- ✅ OAuth2 Password Flow endpoints
- ✅ User, Portfolio, and Transaction models
- ✅ Docker Compose for local development
## License
Private - WealthWise Financial Technologies

149
backend/alembic.ini Normal file
View File

@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = postgresql://bshtechnologies:@localhost:5432/wealthwise
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

103
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,103 @@
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import async_engine_from_config
from alembic import context
# Import your models
from app.models import User, Portfolio, Transaction
from app.models.base import BaseModel
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
target_metadata = BaseModel.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
async def run_async_migrations() -> None:
"""In this scenario we need to create an Engine
and associate a connection with the context.
For async SQLAlchemy, we need to use async_engine_from_config
and run the migrations in an async context.
"""
configuration = config.get_section(config.config_ini_section, {})
# Convert sync URL to async URL for the engine
url = configuration.get('sqlalchemy.url', '').replace(
'postgresql://', 'postgresql+asyncpg://'
)
configuration['sqlalchemy.url'] = url
connectable = async_engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
asyncio.run(run_async_migrations())
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,32 @@
"""Initial migration with user, portfolio, transaction
Revision ID: 5694f7d2f2e9
Revises:
Create Date: 2026-02-14 20:52:03.051559
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '5694f7d2f2e9'
down_revision: Union[str, Sequence[str], None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# WealthWise Backend Application

View File

@@ -0,0 +1 @@
# API package

153
backend/app/api/deps.py Normal file
View File

@@ -0,0 +1,153 @@
"""WealthWise API dependencies.
This module provides reusable dependencies for FastAPI endpoints including:
- Database session injection
- Authentication dependencies with JWT validation
- Common dependency utilities
"""
from typing import Optional
from uuid import UUID
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError, jwt
from sqlalchemy import select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.config import get_settings
from app.core.db import get_session
from app.models import User
from app.schemas.user import TokenPayload
settings = get_settings()
# OAuth2 scheme for JWT token authentication
# Uses the token endpoint for OAuth2 Password Flow
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl=f"{settings.API_V1_STR}/auth/token",
auto_error=False, # Allow optional auth for public endpoints
)
# Database session dependency
# Re-export get_session for cleaner imports in endpoint modules
SessionDep = Depends(get_session)
async def get_current_user(
token: Optional[str] = Depends(oauth2_scheme),
session: AsyncSession = SessionDep,
) -> User:
"""Get current authenticated user from JWT token.
This dependency extracts the Bearer token from the Authorization header,
validates it locally using the application's secret key, queries the
database for the user, and returns the User model.
Args:
token: JWT access token from Authorization header (Bearer token)
session: Database session for user lookup
Returns:
User: Authenticated user model from database
Raises:
HTTPException: 401 if token is missing, invalid, or user not found/inactive
"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
if not token:
raise credentials_exception
try:
# Decode and validate JWT
payload = jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)
# Extract user ID from subject claim
user_id: Optional[str] = payload.get("sub")
if user_id is None:
raise credentials_exception
# Validate token type
token_type = payload.get("type")
if token_type != "access":
raise credentials_exception
except JWTError:
raise credentials_exception
# Query database for user
statement = select(User).where(User.id == UUID(user_id))
result = await session.exec(statement)
user = result.first()
# Validate user exists and is active
if user is None or not user.is_active:
raise credentials_exception
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user),
) -> User:
"""Verify user is active (non-superuser check optional).
This dependency extends get_current_user. Currently just returns the user
since get_current_user already checks is_active. Can be extended for
additional checks like email verification.
Args:
current_user: User from get_current_user dependency
Returns:
Active User model
Raises:
HTTPException: 401 if user is inactive
"""
return current_user
async def get_current_superuser(
current_user: User = Depends(get_current_user),
) -> User:
"""Verify user is a superuser/admin.
This dependency requires the user to have superuser privileges.
Args:
current_user: User from get_current_user dependency
Returns:
Superuser User model
Raises:
HTTPException: 403 if user is not a superuser
"""
if not current_user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions",
)
return current_user
# Convenience exports for endpoint modules
__all__ = [
"get_session",
"SessionDep",
"oauth2_scheme",
"get_current_user",
"get_current_active_user",
"get_current_superuser",
]

View File

@@ -0,0 +1 @@
# API v1 endpoints

38
backend/app/api/v1/api.py Normal file
View File

@@ -0,0 +1,38 @@
"""WealthWise API v1 router aggregation.
This module aggregates all v1 API endpoints into a single router.
Each feature domain should register its endpoints here.
"""
from fastapi import APIRouter
from app.api.v1.endpoints import auth, health, users
api_router = APIRouter()
# Health check endpoints
api_router.include_router(
health.router,
prefix="/health",
tags=["health"],
)
# Authentication endpoints
api_router.include_router(
auth.router,
prefix="/auth",
tags=["authentication"],
)
# User endpoints (requires authentication)
api_router.include_router(
users.router,
prefix="/users",
tags=["users"],
)
# TODO: Add more endpoint routers as features are implemented
# Example:
# from app.api.v1.endpoints import portfolios, transactions
# api_router.include_router(portfolios.router, prefix="/portfolios", tags=["portfolios"])
# api_router.include_router(transactions.router, prefix="/transactions", tags=["transactions"])

View File

@@ -0,0 +1 @@
# API v1 endpoints package

View File

@@ -0,0 +1,131 @@
"""Authentication endpoints for WealthWise.
This module provides endpoints for user registration, login, and token management
using OAuth2 with Password Flow (JWT-based authentication).
"""
from datetime import timedelta
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy import select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import SessionDep
from app.core.security import create_access_token, get_password_hash, verify_password
from app.models import User
from app.schemas.user import Token, UserCreate, UserPublic
router = APIRouter()
@router.post(
"/register",
response_model=UserPublic,
status_code=status.HTTP_201_CREATED,
summary="Register a new user",
description="Create a new user account with email and password.",
response_description="Created user information (excluding password)",
tags=["Authentication"],
)
async def register(
user_in: UserCreate,
session: AsyncSession = SessionDep,
) -> UserPublic:
"""Register a new user account.
This endpoint creates a new user with the provided email and password.
The password is hashed using bcrypt before storage. If the email already
exists, a 400 error is returned.
Args:
user_in: User creation data with email and password
session: Database session
Returns:
UserPublic: Created user information (without password)
Raises:
HTTPException: 400 if email already registered
"""
# Check if user with this email already exists
statement = select(User).where(User.email == user_in.email)
result = await session.exec(statement)
existing_user = result.first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered",
)
# Create new user with hashed password
user = User(
email=user_in.email,
hashed_password=get_password_hash(user_in.password),
is_active=True,
is_superuser=False,
)
session.add(user)
await session.commit()
await session.refresh(user)
return user
@router.post(
"/token",
response_model=Token,
summary="Login and get access token",
description="Authenticate with email and password to receive a JWT access token.",
response_description="JWT access token for authenticated requests",
tags=["Authentication"],
)
async def login_for_access_token(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
session: AsyncSession = SessionDep,
) -> Token:
"""Authenticate user and return JWT access token.
This endpoint accepts username (email) and password via OAuth2 form data,
validates the credentials against the database, and returns a JWT access
token if authentication is successful.
Args:
form_data: OAuth2 form with username (email) and password
session: Database session
Returns:
Token: JWT access token with bearer type
Raises:
HTTPException: 401 if credentials are invalid
"""
# Find user by email (username field in OAuth2 form)
statement = select(User).where(User.email == form_data.username)
result = await session.exec(statement)
user = result.first()
# Validate user exists, is active, and password is correct
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
# Create access token
access_token = create_access_token(
data={"sub": str(user.id)},
)
return Token(access_token=access_token, token_type="bearer")

View File

@@ -0,0 +1,111 @@
"""Health check endpoint for WealthWise API.
This module provides a comprehensive health check endpoint that verifies:
- Application is running
- Database connectivity
- Overall system status
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlmodel.ext.asyncio.session import AsyncSession
from app.api.deps import SessionDep
from app.core.config import get_settings
from app.core.db import check_db_connection
router = APIRouter()
settings = get_settings()
@router.get(
"/health",
summary="Health check endpoint",
description="Performs comprehensive health checks including database connectivity.",
response_description="Health status with version and database state",
tags=["Health"],
)
async def health_check(session: AsyncSession = SessionDep) -> dict:
"""Check application and database health.
This endpoint performs an actual database query (SELECT 1) to verify
that the database connection is working properly. It returns a detailed
health status including:
- Overall application status
- Database connectivity state
- Application version
Returns:
dict: Health status object with the following structure:
{
"status": "healthy" | "degraded",
"database": "connected" | "disconnected",
"version": "1.0.0",
"timestamp": "2024-01-01T00:00:00Z"
}
Raises:
HTTPException: 503 if database is unreachable
"""
health_status = {
"status": "healthy",
"database": "connected",
"version": settings.VERSION,
}
try:
# Perform actual database query to verify connectivity
result = await session.execute(text("SELECT 1"))
db_response = result.scalar()
if db_response != 1:
raise Exception("Unexpected database response")
except Exception as e:
health_status.update({
"status": "degraded",
"database": "disconnected",
"error": str(e),
})
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=health_status,
)
return health_status
@router.get(
"/health/ready",
summary="Readiness probe",
description="Kubernetes-style readiness probe endpoint.",
tags=["Health"],
)
async def readiness_probe() -> dict:
"""Kubernetes readiness probe.
Returns 200 if the application is ready to receive traffic.
Used by container orchestrators to determine when to route traffic.
Returns:
dict: Simple status object
"""
return {"ready": True}
@router.get(
"/health/live",
summary="Liveness probe",
description="Kubernetes-style liveness probe endpoint.",
tags=["Health"],
)
async def liveness_probe() -> dict:
"""Kubernetes liveness probe.
Returns 200 if the application is alive and should not be restarted.
Used by container orchestrators to detect deadlocks or stuck processes.
Returns:
dict: Simple status object
"""
return {"alive": True}

View File

@@ -0,0 +1,63 @@
"""User endpoints for WealthWise API.
This module provides endpoints for user management and profile operations.
All endpoints require authentication via JWT tokens.
"""
from fastapi import APIRouter, Depends
from app.api.deps import get_current_active_user, get_current_user
from app.models import User
from app.schemas.user import UserPublic
router = APIRouter()
@router.get(
"/me",
response_model=UserPublic,
summary="Get current user information",
description="Returns the authenticated user's profile information. "
"This endpoint proves that authentication is working correctly.",
response_description="User information (id, email, is_active)",
tags=["Users"],
)
async def get_current_user_info(
current_user: User = Depends(get_current_user),
) -> UserPublic:
"""Get current authenticated user's information.
This endpoint returns the user's basic information from the database.
It serves as a simple way to verify that authentication is working.
Args:
current_user: User model injected by get_current_user dependency
Returns:
UserPublic with the user's id, email, and account status
"""
return current_user
@router.get(
"/me/active",
response_model=UserPublic,
summary="Get current active user",
description="Returns the authenticated user's profile. "
"Demonstrates the get_current_active_user dependency.",
tags=["Users"],
)
async def get_active_user_info(
current_user: User = Depends(get_current_active_user),
) -> UserPublic:
"""Get current active user's information.
This endpoint demonstrates the get_current_active_user dependency.
Args:
current_user: User model injected by get_current_active_user dependency
Returns:
UserPublic with the user's information
"""
return current_user

View File

@@ -0,0 +1 @@
# Core package - configuration and database

129
backend/app/core/config.py Normal file
View File

@@ -0,0 +1,129 @@
"""WealthWise application configuration management.
This module provides centralized configuration management using Pydantic Settings v2.
Configuration values are loaded from environment variables and .env files.
"""
from functools import lru_cache
from typing import Any, Optional, Union
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Application settings management.
All configuration values are loaded from environment variables.
The .env file is automatically loaded when present.
System environment variables override values in .env.
Attributes:
PROJECT_NAME: Application name for documentation and logging
API_V1_STR: Base path for API v1 endpoints
VERSION: Application version string
DATABASE_URL: PostgreSQL connection string (Supabase Transaction Pooler format)
SUPABASE_JWT_SECRET: JWT secret for Supabase authentication
DEBUG: Enable debug mode (default: False)
CORS_ORIGINS: Comma-separated list of allowed CORS origins
"""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore", # Allow extra env vars without raising errors
case_sensitive=True,
)
# Application Info
PROJECT_NAME: str = Field(default="WealthWise", description="Application name")
API_V1_STR: str = Field(default="/api/v1", description="API v1 base path")
VERSION: str = Field(default="1.0.0", description="Application version")
# Security
SECRET_KEY: str = Field(
default="dev-secret-key-change-in-production",
description="Secret key for JWT signing",
)
ALGORITHM: str = Field(
default="HS256",
description="JWT signing algorithm",
)
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(
default=30,
description="JWT token expiration in minutes",
)
# Database - Local PostgreSQL (Docker)
DATABASE_URL: str = Field(
default="postgresql+asyncpg://postgres:postgres@localhost:5432/wealthwise",
description="PostgreSQL async connection string",
)
# Database Pool Configuration
DB_POOL_SIZE: int = Field(default=20, ge=1, le=100, description="Connection pool size")
DB_MAX_OVERFLOW: int = Field(default=10, ge=0, le=50, description="Max overflow connections")
DB_POOL_PRE_PING: bool = Field(
default=True,
description="Verify connections before using from pool",
)
DB_ECHO: bool = Field(default=False, description="Echo SQL queries to stdout")
# API Configuration
DEBUG: bool = Field(default=False, description="Debug mode")
CORS_ORIGINS: str = Field(
default="http://localhost:5173,http://localhost:3000",
description="Comma-separated list of allowed CORS origins",
)
# Logging
LOG_LEVEL: str = Field(default="INFO", pattern="^(DEBUG|INFO|WARNING|ERROR|CRITICAL)$")
@property
def cors_origins_list(self) -> list[str]:
"""Parse CORS_ORIGINS string into a list.
Returns:
List of origin strings
"""
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
@field_validator("DATABASE_URL")
@classmethod
def validate_database_url(cls, v: Optional[str]) -> Any:
"""Validate and ensure proper asyncpg driver in DATABASE_URL.
Args:
v: Database URL string
Returns:
Validated database URL with asyncpg driver
Raises:
ValueError: If URL format is invalid
"""
if not v:
raise ValueError("DATABASE_URL cannot be empty")
# Ensure asyncpg driver is used
if v.startswith("postgresql://"):
v = v.replace("postgresql://", "postgresql+asyncpg://", 1)
elif not v.startswith("postgresql+asyncpg://"):
raise ValueError(
"DATABASE_URL must use postgresql+asyncpg:// driver for async support"
)
return v
@lru_cache()
def get_settings() -> Settings:
"""Get cached settings instance.
This function uses LRU caching to avoid re-reading configuration
on every call. Settings are loaded once at application startup.
Returns:
Settings instance with all configuration values
"""
return Settings()

123
backend/app/core/db.py Normal file
View File

@@ -0,0 +1,123 @@
"""WealthWise database configuration and session management.
This module provides:
- Async SQLAlchemy engine with connection pooling
- Async session factory for database operations
- FastAPI dependency for session injection
- Connection health checking utilities
"""
from typing import AsyncGenerator
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import (
AsyncEngine,
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.pool import NullPool
from sqlmodel.ext.asyncio.session import AsyncSession as SQLModelAsyncSession
from app.core.config import get_settings
settings = get_settings()
# Create async engine with connection pooling
# Supabase Transaction Pooler (port 6543) supports high concurrency
engine: AsyncEngine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DB_ECHO,
pool_size=settings.DB_POOL_SIZE,
max_overflow=settings.DB_MAX_OVERFLOW,
pool_pre_ping=settings.DB_POOL_PRE_PING,
# Connection pool settings for production
pool_recycle=3600, # Recycle connections after 1 hour
pool_timeout=30, # Wait up to 30 seconds for available connection
# Async-specific settings
future=True,
)
# Create async session factory
# expire_on_commit=False allows accessing attributes after session closes
AsyncSessionLocal = async_sessionmaker(
engine,
class_=SQLModelAsyncSession,
expire_on_commit=False,
autoflush=False,
autocommit=False,
)
async def get_session() -> AsyncGenerator[SQLModelAsyncSession, None]:
"""FastAPI dependency that provides an async database session.
This generator yields a database session for use in API endpoints.
It automatically handles transaction rollback on errors and ensures
proper session cleanup.
Usage:
@app.get("/items")
async def get_items(session: AsyncSession = Depends(get_session)):
result = await session.execute(select(Item))
return result.scalars().all()
Yields:
AsyncSession: Database session instance
Raises:
SQLAlchemyError: Re-raised after rollback if database error occurs
"""
session: SQLModelAsyncSession = AsyncSessionLocal()
try:
yield session
await session.commit()
except SQLAlchemyError as e:
await session.rollback()
raise e
finally:
await session.close()
async def close_engine() -> None:
"""Close all database connections.
Call this during application shutdown to properly release
all database connections in the pool.
"""
await engine.dispose()
async def check_db_connection() -> bool:
"""Verify database connectivity by executing a simple query.
Returns:
True if database is accessible, False otherwise
"""
try:
async with AsyncSessionLocal() as session:
result = await session.execute("SELECT 1")
return result.scalar() == 1
except Exception:
return False
# For Alembic migrations (sync operations)
def create_sync_engine():
"""Create synchronous engine for Alembic migrations.
Returns:
SyncEngine: Synchronous SQLAlchemy engine
"""
from sqlalchemy import create_engine
# Convert async URL to sync URL
sync_url = settings.DATABASE_URL.replace(
"postgresql+asyncpg://", "postgresql://"
)
return create_engine(
sync_url,
echo=settings.DB_ECHO,
)

View File

@@ -0,0 +1,112 @@
"""Security utilities for authentication and password management.
This module provides:
- Password hashing and verification using bcrypt
- JWT token creation and validation
- Security-related helper functions
"""
import bcrypt
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from jose import jwt
from app.core.config import get_settings
settings = get_settings()
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a plain password against a hashed password.
Args:
plain_password: The plain text password to verify
hashed_password: The bcrypt hashed password from database
Returns:
True if password matches, False otherwise
"""
return bcrypt.checkpw(
plain_password.encode('utf-8'),
hashed_password.encode('utf-8')
)
def get_password_hash(password: str) -> str:
"""Generate a bcrypt hash from a plain password.
Args:
password: The plain text password to hash
Returns:
Bcrypt hashed password string
"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
def create_access_token(
data: dict[str, Any],
expires_delta: Optional[timedelta] = None,
) -> str:
"""Create a JWT access token.
This function creates a JWT token with the provided data payload.
The token includes an expiration time and is signed with the
application's secret key.
Args:
data: Dictionary of claims to encode in the token (e.g., {"sub": user_id})
expires_delta: Optional custom expiration time. Defaults to settings.ACCESS_TOKEN_EXPIRE_MINUTES
Returns:
Encoded JWT string
Example:
>>> token = create_access_token({"sub": str(user.id)})
>>> # Use token in Authorization: Bearer <token> header
"""
to_encode = data.copy()
# Calculate expiration time
if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta
else:
expire = datetime.now(timezone.utc) + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
# Add expiration and issued at claims
to_encode.update({
"exp": expire,
"iat": datetime.now(timezone.utc),
"type": "access",
})
# Encode the JWT
encoded_jwt = jwt.encode(
to_encode,
settings.SECRET_KEY,
algorithm=settings.ALGORITHM,
)
return encoded_jwt
def decode_access_token(token: str) -> dict[str, Any]:
"""Decode and validate a JWT access token.
Args:
token: The JWT string to decode
Returns:
Dictionary containing the token payload
Raises:
jwt.JWTError: If token is invalid or expired
"""
return jwt.decode(
token,
settings.SECRET_KEY,
algorithms=[settings.ALGORITHM],
)

130
backend/app/main.py Normal file
View File

@@ -0,0 +1,130 @@
"""WealthWise FastAPI application factory.
This module initializes and configures the FastAPI application with:
- Middleware configuration (CORS, logging, error handling)
- API router registration
- Lifecycle event handlers (startup/shutdown)
- Exception handlers
"""
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from app.api.v1.api import api_router
from app.core.config import get_settings
from app.core.db import check_db_connection, close_engine
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan context manager.
Handles startup and shutdown events:
- Startup: Verify database connectivity, initialize caches
- Shutdown: Close database connections, cleanup resources
Args:
app: FastAPI application instance
Yields:
None: Application runs during this period
"""
# Startup
print(f"Starting {settings.PROJECT_NAME} v{settings.VERSION}")
# Verify database connectivity on startup
db_healthy = await check_db_connection()
if not db_healthy:
print("WARNING: Database connection failed on startup!")
else:
print("Database connection established")
yield
# Shutdown
print(f"Shutting down {settings.PROJECT_NAME}")
await close_engine()
print("Database connections closed")
def create_application() -> FastAPI:
"""Create and configure FastAPI application.
Returns:
Configured FastAPI application instance
"""
application = FastAPI(
title=settings.PROJECT_NAME,
description="Production-grade financial analytics platform API",
version=settings.VERSION,
openapi_url=f"{settings.API_V1_STR}/openapi.json",
docs_url=f"{settings.API_V1_STR}/docs",
redoc_url=f"{settings.API_V1_STR}/redoc",
lifespan=lifespan,
)
# CORS Middleware
# Explicitly allow Authorization header for JWT Bearer tokens
application.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins_list,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"], # Includes Authorization header
expose_headers=["X-Process-Time"], # Expose custom headers
)
# Gzip compression for responses
application.add_middleware(GZipMiddleware, minimum_size=1000)
# Request timing middleware
@application.middleware("http")
async def add_process_time_header(request: Request, call_next):
"""Add X-Process-Time header to all responses."""
start_time = time.time()
response = await call_next(request)
process_time = time.time() - start_time
response.headers["X-Process-Time"] = str(process_time)
return response
# Include API routers
application.include_router(
api_router,
prefix=settings.API_V1_STR,
)
# Root endpoint
@application.get("/")
async def root():
"""Root endpoint - API information."""
return {
"name": settings.PROJECT_NAME,
"version": settings.VERSION,
"docs": f"{settings.API_V1_STR}/docs",
"health": f"{settings.API_V1_STR}/health",
}
# Global exception handlers
@application.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
"""Handle uncaught exceptions."""
return JSONResponse(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
content={
"error": "Internal server error",
"message": str(exc) if settings.DEBUG else "An unexpected error occurred",
},
)
return application
# Create the application instance
app = create_application()

View File

@@ -0,0 +1,25 @@
"""WealthWise database models.
This package contains all SQLModel database models for the application.
"""
from app.models.base import BaseModel, TimestampMixin
from app.models.portfolio import Portfolio, PortfolioBase
from app.models.transaction import Transaction, TransactionBase, TransactionType
from app.models.user import User, UserBase
__all__ = [
# Base models
"BaseModel",
"TimestampMixin",
# User models
"User",
"UserBase",
# Portfolio models
"Portfolio",
"PortfolioBase",
# Transaction models
"Transaction",
"TransactionBase",
"TransactionType",
]

View File

@@ -0,0 +1,88 @@
"""Base SQLModel configuration for WealthWise database models.
This module provides the base model class with common fields and utilities
for all database entities.
"""
from datetime import datetime
from typing import Optional
from uuid import UUID, uuid4
from sqlalchemy import func
from sqlmodel import Field, SQLModel
class BaseModel(SQLModel):
"""Base model with common fields for all database tables.
All models should inherit from this class to get:
- UUID primary key
- Created and updated timestamps
- Soft delete support
Attributes:
id: Unique identifier (UUID)
created_at: Timestamp when record was created
updated_at: Timestamp when record was last updated
deleted_at: Timestamp for soft deletes (None if active)
"""
id: Optional[UUID] = Field(
default_factory=uuid4,
primary_key=True,
index=True,
description="Unique identifier",
)
created_at: Optional[datetime] = Field(
default=None,
sa_column_kwargs={
"server_default": func.now(),
"nullable": False,
},
description="Timestamp when record was created",
)
updated_at: Optional[datetime] = Field(
default=None,
sa_column_kwargs={
"server_default": func.now(),
"onupdate": func.now(),
"nullable": False,
},
description="Timestamp when record was last updated",
)
deleted_at: Optional[datetime] = Field(
default=None,
description="Timestamp for soft delete (None if record is active)",
)
class Config:
"""Pydantic configuration."""
# Allow arbitrary types for UUID handling
arbitrary_types_allowed = True
class TimestampMixin(SQLModel):
"""Mixin for models that only need timestamp fields (no ID).
Use this for association tables or when ID is handled differently.
"""
created_at: Optional[datetime] = Field(
default=None,
sa_column_kwargs={
"server_default": func.now(),
"nullable": False,
},
)
updated_at: Optional[datetime] = Field(
default=None,
sa_column_kwargs={
"server_default": func.now(),
"onupdate": func.now(),
"nullable": False,
},
)

View File

@@ -0,0 +1,76 @@
"""Portfolio model for WealthWise.
This module defines the Portfolio database model. Portfolios belong to users
and contain multiple transactions. Users can have multiple portfolios for
different investment strategies or purposes.
"""
from typing import TYPE_CHECKING, List, Optional
from uuid import UUID
from sqlalchemy import Column, ForeignKey, String, Text
from sqlmodel import Field, Relationship, SQLModel
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.transaction import Transaction
from app.models.user import User
class PortfolioBase(SQLModel):
"""Base Portfolio model with common attributes.
Attributes:
name: Portfolio name
description: Optional portfolio description
"""
name: str = Field(
sa_column=Column(String(255), nullable=False),
description="Portfolio name",
)
description: Optional[str] = Field(
default=None,
sa_column=Column(Text, nullable=True),
description="Optional portfolio description",
)
class Portfolio(BaseModel, PortfolioBase, table=True):
"""Portfolio database model.
Portfolios represent collections of financial transactions owned by a user.
Each portfolio tracks investments, expenses, or savings for a specific purpose.
Attributes:
id: UUID primary key (inherited from BaseModel)
user_id: Foreign key to users table
name: Portfolio name
description: Optional description
user: Relationship to owner
transactions: Relationship to portfolio transactions
created_at: Timestamp (inherited from BaseModel)
updated_at: Timestamp (inherited from BaseModel)
"""
__tablename__ = "portfolios"
user_id: UUID = Field(
sa_column=Column(
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
),
description="Owner user ID",
)
# Relationships
user: "User" = Relationship(back_populates="portfolios")
transactions: List["Transaction"] = Relationship(
back_populates="portfolio",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
)
class Config:
"""Pydantic configuration."""
arbitrary_types_allowed = True

View File

@@ -0,0 +1,104 @@
"""Transaction model for WealthWise.
This module defines the Transaction database model. Transactions represent
individual financial entries (income, expenses, transfers) within a portfolio.
"""
import datetime as dt
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, Optional
from uuid import UUID
from sqlalchemy import Column, Date, DateTime, ForeignKey, Numeric, String, Text
from sqlmodel import Field, Relationship, SQLModel
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.portfolio import Portfolio
class TransactionType(str, Enum):
"""Transaction type enumeration.
Defines the possible types of financial transactions:
- INCOME: Money coming in (salary, dividends, etc.)
- EXPENSE: Money going out (purchases, bills, etc.)
- TRANSFER: Movement between accounts/portfolios
"""
INCOME = "income"
EXPENSE = "expense"
TRANSFER = "transfer"
class TransactionBase(SQLModel):
"""Base Transaction model with common attributes.
Attributes:
amount: Transaction amount (Decimal for financial precision)
transaction_type: Transaction type (income, expense, transfer)
date: Transaction date
description: Optional transaction description
category: Optional transaction category/tag
"""
amount: Decimal = Field(
sa_column=Column(Numeric(15, 2), nullable=False),
description="Transaction amount",
)
transaction_type: TransactionType = Field(
sa_column=Column(String(20), nullable=False),
description="Transaction type: income, expense, or transfer",
)
transaction_date: dt.date = Field(
sa_column=Column(Date, nullable=False),
description="Transaction date",
)
description: Optional[str] = Field(
default=None,
sa_column=Column(Text, nullable=True),
description="Optional transaction description",
)
category: Optional[str] = Field(
default=None,
sa_column=Column(String(100), nullable=True),
description="Optional transaction category or tag",
)
class Transaction(BaseModel, TransactionBase, table=True):
"""Transaction database model.
Transactions represent individual financial entries within a portfolio.
They track amounts, dates, types, and categorization for financial analysis.
Attributes:
id: UUID primary key (inherited from BaseModel)
portfolio_id: Foreign key to portfolios table
amount: Transaction amount with 2 decimal precision
transaction_type: Transaction type enum
date: Transaction date
description: Optional description
category: Optional category/tag
portfolio: Relationship to parent portfolio
created_at: Timestamp (inherited from BaseModel)
updated_at: Not used (transactions are immutable after creation)
"""
__tablename__ = "transactions"
portfolio_id: UUID = Field(
sa_column=Column(
ForeignKey("portfolios.id", ondelete="CASCADE"),
nullable=False,
),
description="Parent portfolio ID",
)
# Relationships
portfolio: "Portfolio" = Relationship(back_populates="transactions")
class Config:
"""Pydantic configuration."""
arbitrary_types_allowed = True

View File

@@ -0,0 +1,76 @@
"""User model for WealthWise.
This module defines the User database model for self-hosted authentication.
Users can have multiple portfolios and are authenticated via JWT tokens.
"""
from typing import TYPE_CHECKING, List, Optional
from uuid import UUID
from sqlalchemy import Boolean, Column, String
from sqlmodel import Field, Relationship, SQLModel
from app.models.base import BaseModel
if TYPE_CHECKING:
from app.models.portfolio import Portfolio
class UserBase(SQLModel):
"""Base User model with common attributes.
Attributes:
email: User's unique email address (indexed)
is_active: Whether the user account is active
is_superuser: Whether user has admin privileges
"""
email: str = Field(
sa_column=Column(String, unique=True, index=True, nullable=False),
description="User's unique email address",
)
is_active: bool = Field(
default=True,
sa_column=Column(Boolean, default=True, nullable=False),
description="Whether the user account is active",
)
is_superuser: bool = Field(
default=False,
sa_column=Column(Boolean, default=False, nullable=False),
description="Whether user has admin privileges",
)
class User(BaseModel, UserBase, table=True):
"""User database model.
This model stores user information including authentication credentials
and account status. Passwords are stored as bcrypt hashes.
Attributes:
id: UUID primary key (inherited from BaseModel)
email: Unique email address
hashed_password: Bcrypt hashed password
is_active: Account status
is_superuser: Admin flag
portfolios: Relationship to user's portfolios
created_at: Timestamp (inherited from BaseModel)
updated_at: Timestamp (inherited from BaseModel)
"""
__tablename__ = "users"
hashed_password: str = Field(
sa_column=Column(String, nullable=False),
description="Bcrypt hashed password",
)
# Relationships
portfolios: List["Portfolio"] = Relationship(
back_populates="user",
sa_relationship_kwargs={"cascade": "all, delete-orphan"},
)
class Config:
"""Pydantic configuration."""
arbitrary_types_allowed = True

View File

@@ -0,0 +1,26 @@
# Schemas package for Pydantic models
from app.schemas.token import TokenPayload, UserContext
from app.schemas.user import (
Token,
TokenPayload as UserTokenPayload,
UserBase,
UserCreate,
UserInDB,
UserPublic,
UserUpdate,
)
__all__ = [
# Token schemas (legacy Supabase support)
"TokenPayload",
"UserContext",
# User schemas (new self-hosted auth)
"Token",
"UserTokenPayload",
"UserBase",
"UserCreate",
"UserPublic",
"UserInDB",
"UserUpdate",
]

View File

@@ -0,0 +1,127 @@
"""Pydantic models for JWT token handling and user context.
This module defines the data structures for:
- TokenPayload: Full JWT payload from Supabase
- UserContext: Clean user representation for API context
"""
from typing import Any, Optional
from pydantic import BaseModel, Field
class TokenPayload(BaseModel):
"""Supabase JWT token payload structure.
This model represents the complete JWT payload that Supabase Auth
includes in the access token. It contains all claims including
standard JWT claims and Supabase-specific claims.
Attributes:
sub: Subject (user UUID)
exp: Expiration timestamp (Unix)
iat: Issued at timestamp (Unix)
aud: Audience (should be "authenticated")
email: User's email address
phone: User's phone number (if available)
app_metadata: Application-specific metadata (includes role)
user_metadata: User-specific metadata
role: User's database role (e.g., "authenticated", "anon")
aal: Authenticator assurance level (aal1, aal2)
amr: Authentication methods reference
session_id: Session UUID
is_anonymous: Whether this is an anonymous user
"""
# Standard JWT claims
sub: str = Field(description="Subject (user UUID)")
exp: Optional[int] = Field(default=None, description="Expiration timestamp (Unix)")
iat: Optional[int] = Field(default=None, description="Issued at timestamp (Unix)")
iss: Optional[str] = Field(default=None, description="Issuer")
aud: str = Field(description="Audience (should be 'authenticated')")
# User identity claims
email: Optional[str] = Field(default=None, description="User's email address")
phone: Optional[str] = Field(default=None, description="User's phone number")
email_confirmed_at: Optional[str] = Field(default=None, description="Email confirmation timestamp")
phone_confirmed_at: Optional[str] = Field(default=None, description="Phone confirmation timestamp")
# Supabase metadata
app_metadata: dict[str, Any] = Field(
default_factory=dict,
description="Application-specific metadata (provider, role, etc.)"
)
user_metadata: dict[str, Any] = Field(
default_factory=dict,
description="User-specific metadata"
)
# Supabase-specific claims
role: Optional[str] = Field(default=None, description="Database role")
aal: Optional[str] = Field(default=None, description="Authenticator assurance level")
amr: Optional[list[dict[str, Any]]] = Field(default=None, description="Authentication methods reference")
session_id: Optional[str] = Field(default=None, description="Session UUID")
is_anonymous: Optional[bool] = Field(default=False, description="Whether user is anonymous")
class Config:
"""Pydantic configuration."""
extra = "allow" # Allow additional claims not explicitly defined
class UserContext(BaseModel):
"""Clean user context for API endpoints.
This model represents the authenticated user information that
is passed to API endpoints via the get_current_user dependency.
It contains only the essential information needed by most endpoints.
Attributes:
id: User UUID from the 'sub' claim
email: User's email address
role: User's role (extracted from app_metadata or role claim)
is_anonymous: Whether this is an anonymous user
session_id: Current session ID
"""
id: str = Field(description="User UUID")
email: Optional[str] = Field(default=None, description="User's email address")
role: str = Field(default="authenticated", description="User's role")
is_anonymous: bool = Field(default=False, description="Whether user is anonymous")
session_id: Optional[str] = Field(default=None, description="Current session ID")
@classmethod
def from_token_payload(cls, payload: TokenPayload) -> "UserContext":
"""Create UserContext from TokenPayload.
Extracts user information from the full JWT payload and creates
a clean UserContext object. Role is extracted from app_metadata
if available, otherwise defaults to the role claim or 'authenticated'.
Args:
payload: Validated TokenPayload from JWT
Returns:
UserContext with extracted user information
"""
# Extract role from app_metadata or fall back to role claim
role = payload.app_metadata.get("role", payload.role or "authenticated")
return cls(
id=payload.sub,
email=payload.email,
role=role,
is_anonymous=payload.is_anonymous or False,
session_id=payload.session_id,
)
class Config:
"""Pydantic configuration."""
json_schema_extra = {
"example": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@wealthwise.app",
"role": "authenticated",
"is_anonymous": False,
"session_id": "session-uuid-here",
}
}

View File

@@ -0,0 +1,92 @@
"""User schemas for request and response validation.
This module defines Pydantic schemas for user-related operations including
registration, authentication, and user information retrieval.
"""
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
class UserBase(BaseModel):
"""Base user schema with common attributes."""
email: EmailStr = Field(description="User's email address")
is_active: bool = Field(default=True, description="Whether account is active")
is_superuser: bool = Field(default=False, description="Whether user is admin")
class UserCreate(BaseModel):
"""Schema for user registration.
Attributes:
email: User's email address (must be unique)
password: Plain text password (will be hashed)
"""
email: EmailStr = Field(description="User's email address")
password: str = Field(
min_length=8,
description="User's password (minimum 8 characters)",
)
class UserUpdate(BaseModel):
"""Schema for user updates.
All fields are optional to allow partial updates.
"""
email: Optional[EmailStr] = Field(default=None, description="New email address")
password: Optional[str] = Field(
default=None,
min_length=8,
description="New password (minimum 8 characters)",
)
is_active: Optional[bool] = Field(default=None, description="Account status")
class UserInDB(UserBase):
"""Schema representing user as stored in database.
Includes the hashed password - should never be returned in API responses.
"""
id: UUID
hashed_password: str = Field(description="Bcrypt hashed password")
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class UserPublic(UserBase):
"""Schema for user information returned in API responses.
Excludes sensitive fields like hashed_password.
"""
id: UUID
created_at: datetime
class Config:
from_attributes = True
class Token(BaseModel):
"""Schema for authentication token response."""
access_token: str = Field(description="JWT access token")
token_type: str = Field(default="bearer", description="Token type")
class TokenPayload(BaseModel):
"""Schema for JWT token payload."""
sub: Optional[str] = Field(default=None, description="Subject (user ID)")
exp: Optional[datetime] = Field(default=None, description="Expiration time")
type: Optional[str] = Field(default=None, description="Token type")

View File

@@ -0,0 +1,54 @@
version: "3.8"
services:
# PostgreSQL Database
db:
image: postgres:15-alpine
container_name: wealthwise-db
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: wealthwise
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
networks:
- wealthwise-network
# FastAPI Backend
backend:
build:
context: .
dockerfile: Dockerfile
container_name: wealthwise-backend
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/wealthwise
SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-in-production}
ALGORITHM: HS256
ACCESS_TOKEN_EXPIRE_MINUTES: 30
DEBUG: "false"
PROJECT_NAME: WealthWise
API_V1_STR: /api/v1
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
volumes:
- .:/app
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
networks:
- wealthwise-network
volumes:
postgres_data:
networks:
wealthwise-network:
driver: bridge

2268
backend/poetry.lock generated Normal file

File diff suppressed because it is too large Load Diff

100
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,100 @@
[tool.poetry]
name = "wealthwise-backend"
version = "1.0.0"
description = "Production-grade FastAPI backend for WealthWise financial analytics platform"
authors = ["WealthWise Team <team@wealthwise.app>"]
readme = "README.md"
packages = [{include = "app"}]
[tool.poetry.dependencies]
python = "^3.11"
fastapi = "^0.109.0"
uvicorn = {extras = ["standard"], version = "^0.27.0"}
python-multipart = "^0.0.6"
# Database
sqlmodel = "^0.0.14"
asyncpg = "^0.29.0"
alembic = "^1.13.0"
# Configuration & Validation
pydantic = "^2.6.0"
pydantic-settings = "^2.1.0"
# Auth
python-jose = {extras = ["cryptography"], version = "^3.3.0"}
bcrypt = "^4.2.0"
# Utilities
python-dotenv = "^1.0.0"
httpx = "^0.26.0"
tenacity = "^8.2.3"
# Monitoring & Logging
structlog = "^24.1.0"
sentry-sdk = {extras = ["fastapi"], version = "^1.40.0"}
greenlet = "^3.3.1"
email-validator = "^2.3.0"
[tool.poetry.group.dev.dependencies]
pytest = "^8.0.0"
pytest-asyncio = "^0.23.4"
pytest-cov = "^4.1.0"
httpx = "^0.26.0"
black = "^24.1.0"
isort = "^5.13.0"
flake8 = "^7.0.0"
mypy = "^1.8.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.black]
line-length = 88
target-version = ["py311"]
[tool.isort]
profile = "black"
line_length = 88
[tool.mypy]
python_version = "3.11"
warn_return_any = true
warn_unused_configs = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
disallow_untyped_decorators = false
no_implicit_optional = true
warn_redundant_casts = true
warn_unused_ignores = true
warn_no_return = true
warn_unreachable = true
strict_equality = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = "-v --cov=app --cov-report=term-missing"
[tool.coverage.run]
source = ["app"]
branch = true
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if self.debug:",
"if settings.DEBUG",
"raise AssertionError",
"raise NotImplementedError",
"if 0:",
"if __name__ == .__main__.:",
"class .*\\bProtocol\\):",
"@(abc\\.)?abstractmethod",
]

View File

@@ -0,0 +1,25 @@
"""Initialize database tables.
This script creates all database tables defined in the models.
Run this before starting the application for the first time.
"""
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from app.core.config import get_settings
from app.models.base import BaseModel
settings = get_settings()
async def init_db():
"""Create all database tables."""
engine = create_async_engine(settings.DATABASE_URL, echo=True)
async with engine.begin() as conn:
await conn.run_sync(BaseModel.metadata.create_all)
await engine.dispose()
print("Database tables created successfully!")
if __name__ == "__main__":
asyncio.run(init_db())

View File

@@ -0,0 +1,57 @@
#!/bin/bash
# WealthWise Database Migration Script
# This script initializes Alembic and creates the initial migration
echo "Setting up Alembic migrations for WealthWise..."
# Check if alembic is installed
if ! command -v alembic &> /dev/null; then
echo "Installing alembic..."
pip install alembic
fi
# Initialize Alembic if not already initialized
if [ ! -d "alembic" ]; then
echo "Initializing Alembic..."
alembic init alembic
# Update alembic.ini with database URL
sed -i '' 's|sqlalchemy.url = driver://user:pass@localhost/dbname|sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/wealthwise|' alembic/alembic.ini 2>/dev/null || sed -i 's|sqlalchemy.url = driver://user:pass@localhost/dbname|sqlalchemy.url = postgresql://postgres:postgres@localhost:5432/wealthwise|' alembic/alembic.ini
echo "Alembic initialized successfully!"
else
echo "Alembic already initialized."
fi
echo ""
echo "Next steps:"
echo "1. Update alembic/env.py to import your models (see instructions below)"
echo "2. Run: alembic revision --autogenerate -m 'Initial migration'"
echo "3. Run: alembic upgrade head"
echo ""
echo "=== alembic/env.py Configuration ==="
echo "Add these imports to alembic/env.py:"
echo ""
echo "import asyncio"
echo "from sqlalchemy.ext.asyncio import AsyncEngine"
echo "from app.models import User, Portfolio, Transaction"
echo "from app.models.base import BaseModel"
echo ""
echo "Then update run_migrations_online() to use async engine:"
echo ""
echo "def do_run_migrations(connection):"
echo " context.configure(connection=connection, target_metadata=BaseModel.metadata)"
echo " with context.begin_transaction():"
echo " context.run_migrations()"
echo ""
echo "async def run_migrations_online():"
echo " connectable = AsyncEngine(create_async_engine(config.get_main_option('sqlalchemy.url')))"
echo " async with connectable.connect() as connection:"
echo " await connection.run_sync(do_run_migrations)"
echo " await connectable.dispose()"
echo ""
echo "if context.is_offline_mode():"
echo " run_migrations_offline()"
echo "else:"
echo " asyncio.run(run_migrations_online())"

195
backend/scripts/seed_db.py Normal file
View File

@@ -0,0 +1,195 @@
"""Database seeding script for WealthWise.
This script populates the database with sample data for development and testing.
"""
import asyncio
from datetime import date, timedelta
from decimal import Decimal
from uuid import uuid4
from sqlalchemy import select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.db import AsyncSessionLocal, engine
from app.core.security import get_password_hash
from app.models import Portfolio, Transaction, User
from app.models.transaction import TransactionType
async def seed_database():
"""Seed the database with sample data."""
print("Starting database seeding...")
async with AsyncSessionLocal() as session:
# Check if demo user already exists
result = await session.execute(
select(User).where(User.email == "demo@wealthwise.app")
)
existing_user = result.scalar_one_or_none()
if existing_user:
print("Demo user already exists. Skipping seeding.")
return
# Create demo user
print("Creating demo user...")
demo_user = User(
id=uuid4(),
email="demo@wealthwise.app",
hashed_password=get_password_hash("password123"),
is_active=True,
is_superuser=False,
)
session.add(demo_user)
await session.flush() # Flush to get the user ID
print(f"Created user: {demo_user.email} (ID: {demo_user.id})")
# Create portfolio
print("Creating portfolio...")
portfolio = Portfolio(
id=uuid4(),
user_id=demo_user.id,
name="Main Investment",
description="Primary investment portfolio for tracking stocks, mutual funds, and other assets.",
)
session.add(portfolio)
await session.flush()
print(f"Created portfolio: {portfolio.name} (ID: {portfolio.id})")
# Create sample transactions
print("Creating transactions...")
transactions = [
# Income transactions
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("75000.00"),
transaction_type=TransactionType.INCOME,
transaction_date=date.today() - timedelta(days=30),
description="Monthly Salary",
category="Salary",
),
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("2500.00"),
transaction_type=TransactionType.INCOME,
transaction_date=date.today() - timedelta(days=25),
description="Freelance Project Payment",
category="Freelance",
),
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("1200.00"),
transaction_type=TransactionType.INCOME,
transaction_date=date.today() - timedelta(days=20),
description="Stock Dividend - TCS",
category="Dividend",
),
# Expense transactions
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("25000.00"),
transaction_type=TransactionType.EXPENSE,
transaction_date=date.today() - timedelta(days=28),
description="Monthly Rent",
category="Housing",
),
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("8000.00"),
transaction_type=TransactionType.EXPENSE,
transaction_date=date.today() - timedelta(days=26),
description="Grocery Shopping - BigBasket",
category="Groceries",
),
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("3500.00"),
transaction_type=TransactionType.EXPENSE,
transaction_date=date.today() - timedelta(days=22),
description="Electricity & Internet Bill",
category="Utilities",
),
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("5000.00"),
transaction_type=TransactionType.EXPENSE,
transaction_date=date.today() - timedelta(days=18),
description="Dining Out & Entertainment",
category="Entertainment",
),
# Investment transactions
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("10000.00"),
transaction_type=TransactionType.TRANSFER,
transaction_date=date.today() - timedelta(days=24),
description="Stock Purchase - Infosys",
category="Investment",
),
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("5000.00"),
transaction_type=TransactionType.TRANSFER,
transaction_date=date.today() - timedelta(days=21),
description="Mutual Fund SIP - SBI Bluechip",
category="Investment",
),
Transaction(
id=uuid4(),
portfolio_id=portfolio.id,
amount=Decimal("3000.00"),
transaction_type=TransactionType.TRANSFER,
transaction_date=date.today() - timedelta(days=15),
description="Crypto Purchase - Bitcoin",
category="Investment",
),
]
for transaction in transactions:
session.add(transaction)
await session.commit()
print(f"Created {len(transactions)} transactions")
# Calculate summary
income_total = sum(t.amount for t in transactions if t.transaction_type == TransactionType.INCOME)
expense_total = sum(t.amount for t in transactions if t.transaction_type == TransactionType.EXPENSE)
transfer_total = sum(t.amount for t in transactions if t.transaction_type == TransactionType.TRANSFER)
print("\n" + "="*50)
print("SEEDING COMPLETE!")
print("="*50)
print(f"User: {demo_user.email}")
print(f"Password: password123")
print(f"Portfolio: {portfolio.name}")
print(f"\nTransaction Summary:")
print(f" Total Income: ₹{income_total:,.2f}")
print(f" Total Expenses: ₹{expense_total:,.2f}")
print(f" Total Investments: ₹{transfer_total:,.2f}")
print(f" Net Cash Flow: ₹{(income_total - expense_total):,.2f}")
print("="*50)
async def main():
"""Main entry point for seeding."""
try:
await seed_database()
except Exception as e:
print(f"Error seeding database: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1 @@
# Tests package for WealthWise Backend

246
backend/tests/test_auth.py Normal file
View File

@@ -0,0 +1,246 @@
"""Tests for authentication layer.
This module tests JWT validation and authentication dependencies.
"""
import time
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from fastapi.testclient import TestClient
from jose import jwt
from app.api.deps import get_current_user, validate_supabase_token
from app.core.config import get_settings
from app.main import app
from app.schemas.token import TokenPayload, UserContext
settings = get_settings()
class TestJWTValidation:
"""Test cases for JWT token validation."""
def create_test_token(
self,
secret: str = None,
expired: bool = False,
wrong_audience: bool = False,
missing_claims: bool = False,
algorithm: str = "HS256",
) -> str:
"""Create a test JWT token with specified properties.
Args:
secret: JWT signing secret (defaults to settings.SUPABASE_JWT_SECRET)
expired: Whether the token should be expired
wrong_audience: Whether to use wrong audience
missing_claims: Whether to omit required claims
algorithm: Signing algorithm
Returns:
Encoded JWT string
"""
secret = secret or settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY
# Base payload
payload = {
"sub": "550e8400-e29b-41d4-a716-446655440000",
"email": "test@wealthwise.app",
"aud": "wrong-audience" if wrong_audience else "authenticated",
"role": "authenticated",
"app_metadata": {},
"user_metadata": {},
}
if not missing_claims:
# Set expiration
if expired:
payload["exp"] = int(time.time()) - 3600 # 1 hour ago
else:
payload["exp"] = int(time.time()) + 3600 # 1 hour from now
return jwt.encode(payload, secret, algorithm=algorithm)
def test_validate_valid_token(self):
"""Test validation of a valid JWT token."""
token = self.create_test_token()
payload = validate_supabase_token(token)
assert isinstance(payload, TokenPayload)
assert payload.sub == "550e8400-e29b-41d4-a716-446655440000"
assert payload.email == "test@wealthwise.app"
assert payload.aud == "authenticated"
def test_validate_expired_token(self):
"""Test that expired tokens are rejected."""
token = self.create_test_token(expired=True)
with pytest.raises(HTTPException) as exc_info:
validate_supabase_token(token)
assert exc_info.value.status_code == 401
assert "Could not validate credentials" in exc_info.value.detail
def test_validate_wrong_audience(self):
"""Test that tokens with wrong audience are rejected."""
token = self.create_test_token(wrong_audience=True)
with pytest.raises(HTTPException) as exc_info:
validate_supabase_token(token)
assert exc_info.value.status_code == 401
def test_validate_invalid_signature(self):
"""Test that tokens with invalid signature are rejected."""
token = self.create_test_token(secret="wrong-secret")
with pytest.raises(HTTPException) as exc_info:
validate_supabase_token(token)
assert exc_info.value.status_code == 401
def test_validate_missing_exp(self):
"""Test that tokens without expiration are rejected."""
token = self.create_test_token(missing_claims=True)
with pytest.raises(HTTPException) as exc_info:
validate_supabase_token(token)
assert exc_info.value.status_code == 401
class TestGetCurrentUser:
"""Test cases for get_current_user dependency."""
def test_get_current_user_valid_token(self):
"""Test that valid token returns UserContext."""
# Create a valid token
payload = {
"sub": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@wealthwise.app",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
"role": "authenticated",
"app_metadata": {},
"user_metadata": {},
}
token = jwt.encode(
payload,
settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY,
algorithm="HS256",
)
# Since get_current_user is async, we need to run it in an event loop
import asyncio
async def test():
return await get_current_user(token)
user = asyncio.run(test())
assert isinstance(user, UserContext)
assert user.id == "550e8400-e29b-41d4-a716-446655440000"
assert user.email == "user@wealthwise.app"
assert user.role == "authenticated"
def test_get_current_user_no_token(self):
"""Test that missing token raises 401."""
import asyncio
async def test():
return await get_current_user(None)
with pytest.raises(HTTPException) as exc_info:
asyncio.run(test())
assert exc_info.value.status_code == 401
def test_get_current_user_invalid_token(self):
"""Test that invalid token raises 401."""
import asyncio
async def test():
return await get_current_user("invalid-token")
with pytest.raises(HTTPException) as exc_info:
asyncio.run(test())
assert exc_info.value.status_code == 401
class TestUserContext:
"""Test cases for UserContext model."""
def test_from_token_payload(self):
"""Test conversion from TokenPayload to UserContext."""
payload = TokenPayload(
sub="550e8400-e29b-41d4-a716-446655440000",
email="test@wealthwise.app",
aud="authenticated",
role="authenticated",
app_metadata={"role": "admin"},
)
context = UserContext.from_token_payload(payload)
assert context.id == "550e8400-e29b-41d4-a716-446655440000"
assert context.email == "test@wealthwise.app"
assert context.role == "admin" # From app_metadata
def test_from_token_payload_no_app_metadata_role(self):
"""Test fallback to role claim when app_metadata has no role."""
payload = TokenPayload(
sub="550e8400-e29b-41d4-a716-446655440000",
email="test@wealthwise.app",
aud="authenticated",
role="authenticated",
app_metadata={},
)
context = UserContext.from_token_payload(payload)
assert context.role == "authenticated" # From role claim
class TestProtectedEndpoints:
"""Integration tests for protected endpoints."""
def test_me_endpoint_without_auth(self):
"""Test that /me endpoint requires authentication."""
client = TestClient(app)
response = client.get("/api/v1/users/me")
assert response.status_code == 401
def test_me_endpoint_with_valid_auth(self):
"""Test that /me endpoint works with valid token."""
# Create valid token
payload = {
"sub": "550e8400-e29b-41d4-a716-446655440000",
"email": "user@wealthwise.app",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
"role": "authenticated",
"app_metadata": {},
"user_metadata": {},
}
token = jwt.encode(
payload,
settings.SUPABASE_JWT_SECRET or settings.SECRET_KEY,
algorithm="HS256",
)
client = TestClient(app)
response = client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
data = response.json()
assert data["id"] == "550e8400-e29b-41d4-a716-446655440000"
assert data["email"] == "user@wealthwise.app"

View File

@@ -0,0 +1,193 @@
"""Tests for self-hosted authentication system.
This module tests the JWT-based authentication with OAuth2 Password Flow.
"""
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import select
from sqlmodel.ext.asyncio.session import AsyncSession
from app.core.security import create_access_token, get_password_hash, verify_password
from app.main import app
from app.models import User
from app.schemas.user import UserCreate
client = TestClient(app)
class TestSecurityUtilities:
"""Test cases for security utilities."""
def test_password_hashing(self):
"""Test password hashing and verification."""
password = "testpassword123"
hashed = get_password_hash(password)
# Hash should be different from plain password
assert hashed != password
# Verification should succeed
assert verify_password(password, hashed) is True
# Wrong password should fail
assert verify_password("wrongpassword", hashed) is False
def test_create_access_token(self):
"""Test JWT token creation."""
data = {"sub": "123e4567-e89b-12d3-a456-426614174000"}
token = create_access_token(data)
assert token is not None
assert isinstance(token, str)
# JWT tokens have 3 parts separated by dots
assert len(token.split(".")) == 3
class TestAuthEndpoints:
"""Test cases for authentication endpoints."""
def test_register_success(self):
"""Test successful user registration."""
response = client.post(
"/api/v1/auth/register",
json={
"email": "test@wealthwise.app",
"password": "testpassword123",
},
)
assert response.status_code == 201
data = response.json()
assert data["email"] == "test@wealthwise.app"
assert "id" in data
assert "hashed_password" not in data
assert data["is_active"] is True
def test_register_duplicate_email(self):
"""Test registration with duplicate email."""
# First registration
client.post(
"/api/v1/auth/register",
json={
"email": "duplicate@wealthwise.app",
"password": "testpassword123",
},
)
# Second registration with same email
response = client.post(
"/api/v1/auth/register",
json={
"email": "duplicate@wealthwise.app",
"password": "testpassword123",
},
)
assert response.status_code == 400
assert "email already registered" in response.json()["detail"].lower()
def test_login_success(self):
"""Test successful login."""
# Register first
client.post(
"/api/v1/auth/register",
json={
"email": "login@wealthwise.app",
"password": "testpassword123",
},
)
# Login
response = client.post(
"/api/v1/auth/token",
data={
"username": "login@wealthwise.app",
"password": "testpassword123",
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
def test_login_invalid_credentials(self):
"""Test login with invalid credentials."""
response = client.post(
"/api/v1/auth/token",
data={
"username": "nonexistent@wealthwise.app",
"password": "wrongpassword",
},
)
assert response.status_code == 401
def test_login_wrong_password(self):
"""Test login with wrong password."""
# Register first
client.post(
"/api/v1/auth/register",
json={
"email": "wrongpass@wealthwise.app",
"password": "testpassword123",
},
)
# Login with wrong password
response = client.post(
"/api/v1/auth/token",
data={
"username": "wrongpass@wealthwise.app",
"password": "wrongpassword",
},
)
assert response.status_code == 401
class TestProtectedEndpoints:
"""Test cases for protected endpoints."""
def test_me_without_auth(self):
"""Test accessing /me without authentication."""
response = client.get("/api/v1/users/me")
assert response.status_code == 401
def test_me_with_valid_token(self):
"""Test accessing /me with valid token."""
# Register and login
register_response = client.post(
"/api/v1/auth/register",
json={
"email": "protected@wealthwise.app",
"password": "testpassword123",
},
)
login_response = client.post(
"/api/v1/auth/token",
data={
"username": "protected@wealthwise.app",
"password": "testpassword123",
},
)
token = login_response.json()["access_token"]
# Access protected endpoint
response = client.get(
"/api/v1/users/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
data = response.json()
assert data["email"] == "protected@wealthwise.app"
def test_me_with_invalid_token(self):
"""Test accessing /me with invalid token."""
response = client.get(
"/api/v1/users/me",
headers={"Authorization": "Bearer invalid-token"},
)
assert response.status_code == 401

View File

@@ -0,0 +1,55 @@
"""Tests for core configuration module."""
import os
import pytest
from app.core.config import Settings, get_settings
class TestSettings:
"""Test cases for Settings configuration."""
def test_default_values(self):
"""Test that default values are set correctly."""
settings = Settings()
assert settings.PROJECT_NAME == "WealthWise"
assert settings.API_V1_STR == "/api/v1"
assert settings.VERSION == "1.0.0"
assert settings.DB_POOL_SIZE == 20
assert settings.DB_MAX_OVERFLOW == 10
assert settings.DEBUG is False
def test_database_url_validation(self):
"""Test that database URL is validated and converted to asyncpg."""
# Test conversion from postgresql:// to postgresql+asyncpg://
settings = Settings(DATABASE_URL="postgresql://user:pass@localhost:5432/db")
assert settings.DATABASE_URL.startswith("postgresql+asyncpg://")
# Test that asyncpg URL is preserved
asyncpg_url = "postgresql+asyncpg://user:pass@localhost:6543/postgres"
settings2 = Settings(DATABASE_URL=asyncpg_url)
assert settings2.DATABASE_URL == asyncpg_url
def test_cors_origins_parsing(self):
"""Test CORS origins parsing from string."""
settings = Settings(CORS_ORIGINS="http://localhost:5173,https://example.com")
assert "http://localhost:5173" in settings.CORS_ORIGINS
assert "https://example.com" in settings.CORS_ORIGINS
# Test list input
settings2 = Settings(CORS_ORIGINS=["http://localhost:3000"])
assert settings2.CORS_ORIGINS == ["http://localhost:3000"]
class TestGetSettings:
"""Test cases for get_settings function."""
def test_get_settings_returns_singleton(self):
"""Test that get_settings returns a cached singleton."""
settings1 = get_settings()
settings2 = get_settings()
# Should be the same object (cached)
assert settings1 is settings2

View File

@@ -0,0 +1,40 @@
"""Tests for health check endpoint."""
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
class TestHealthCheck:
"""Test cases for health check endpoint."""
def test_health_endpoint_exists(self):
"""Test that health endpoint is accessible."""
response = client.get("/api/v1/health")
assert response.status_code in [200, 503] # 200 if DB connected, 503 if not
def test_health_response_structure(self):
"""Test that health response has expected structure."""
response = client.get("/api/v1/health")
data = response.json()
assert "status" in data
assert "database" in data
assert "version" in data
def test_readiness_probe(self):
"""Test readiness probe endpoint."""
response = client.get("/api/v1/health/ready")
assert response.status_code == 200
assert response.json() == {"ready": True}
def test_liveness_probe(self):
"""Test liveness probe endpoint."""
response = client.get("/api/v1/health/live")
assert response.status_code == 200
assert response.json() == {"alive": True}