Initial commit: Wallet App - FastAPI backend with React frontend for personal finance tracking

This commit is contained in:
RemTools
2026-09-04 08:37:14 +00:00 Unverified
commit d7b52f24d2
39572 changed files with 1617122 additions and 0 deletions
View File
+71
View File
@@ -0,0 +1,71 @@
"""Authentication utilities."""
import os
from datetime import datetime, timedelta
from typing import Optional
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthCredentials
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
# Security configuration
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()
def hash_password(password: str) -> str:
"""Hash a password."""
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against its hash."""
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
"""Create a JWT access token."""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def get_current_user(
credentials: HTTPAuthCredentials = Depends(security),
db: Session = Depends(get_db)
) -> User:
"""Get the current authenticated user."""
token = credentials.credentials
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
user_id: int = payload.get("sub")
if user_id is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
except JWTError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
)
user = db.query(User).filter(User.id == user_id).first()
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="User not found",
)
return user
+30
View File
@@ -0,0 +1,30 @@
"""Database configuration and session management."""
import os
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
# Use SQLite database
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./data/app.db")
# Create data directory if it doesn't exist
if DATABASE_URL.startswith("sqlite"):
os.makedirs("./data", exist_ok=True)
engine = create_engine(
DATABASE_URL,
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency to get database session."""
db = SessionLocal()
try:
yield db
finally:
db.close()
+28
View File
@@ -0,0 +1,28 @@
"""FastAPI entry point. Deploy imports ``app.main:app`` — keep both names."""
import os
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.routes import api_router
from app.database import Base, engine
# Create database tables
Base.metadata.create_all(bind=engine)
app = FastAPI(title=os.getenv("APP_NAME", "app"))
# Allowed origins come from the environment so the deployed app and local dev
# use the same code path. "*" only when nothing is configured (dev default).
_origins = [o.strip() for o in os.getenv("CORS_ORIGINS", "*").split(",") if o.strip()]
app.add_middleware(
CORSMiddleware,
allow_origins=_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# The one place the /api prefix is applied. Route modules must not repeat it.
app.include_router(api_router, prefix="/api")
+43
View File
@@ -0,0 +1,43 @@
"""Database models for the wallet app."""
from datetime import datetime
from sqlalchemy import Column, Integer, String, Float, DateTime, ForeignKey, Enum
from sqlalchemy.orm import relationship
from app.database import Base
import enum
class EntryType(str, enum.Enum):
"""Type of wallet entry."""
INCOME = "income"
EXPENSE = "expense"
class User(Base):
"""User model for authentication."""
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
username = Column(String, unique=True, index=True)
email = Column(String, unique=True, index=True)
hashed_password = Column(String)
created_at = Column(DateTime, default=datetime.utcnow)
# Relationships
wallet_entries = relationship("WalletEntry", back_populates="user", cascade="all, delete-orphan")
class WalletEntry(Base):
"""Wallet entry model for tracking income and expenses."""
__tablename__ = "wallet_entries"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), index=True)
amount = Column(Float)
entry_type = Column(Enum(EntryType), index=True)
description = Column(String)
created_at = Column(DateTime, default=datetime.utcnow, index=True)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Relationships
user = relationship("User", back_populates="wallet_entries")
+17
View File
@@ -0,0 +1,17 @@
"""Route barrel — every router the app serves is mounted here.
Adding a resource is two steps: create ``app/routes/<name>.py`` with a
module-level ``router``, then append one ``include_router`` line below. A route
file that is never mounted 404s silently.
"""
from fastapi import APIRouter
from app.routes import health, items, auth, wallet
api_router = APIRouter()
api_router.include_router(health.router, tags=["health"])
api_router.include_router(items.router, tags=["items"])
api_router.include_router(auth.router, tags=["auth"])
api_router.include_router(wallet.router, tags=["wallet"])
+66
View File
@@ -0,0 +1,66 @@
"""Authentication routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User
from app.schemas import UserRegister, UserLogin, TokenResponse, UserResponse
from app.auth import hash_password, verify_password, create_access_token
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=TokenResponse)
def register(user_data: UserRegister, db: Session = Depends(get_db)):
"""Register a new user."""
# Check if user already exists
existing_user = db.query(User).filter(
(User.username == user_data.username) | (User.email == user_data.email)
).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Username or email already registered"
)
# Create new user
hashed_password = hash_password(user_data.password)
new_user = User(
username=user_data.username,
email=user_data.email,
hashed_password=hashed_password
)
db.add(new_user)
db.commit()
db.refresh(new_user)
# Create access token
access_token = create_access_token(data={"sub": new_user.id})
return {
"access_token": access_token,
"token_type": "bearer",
"user": UserResponse.from_orm(new_user)
}
@router.post("/login", response_model=TokenResponse)
def login(user_data: UserLogin, db: Session = Depends(get_db)):
"""Login a user."""
user = db.query(User).filter(User.username == user_data.username).first()
if not user or not verify_password(user_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid username or password"
)
# Create access token
access_token = create_access_token(data={"sub": user.id})
return {
"access_token": access_token,
"token_type": "bearer",
"user": UserResponse.from_orm(user)
}
+15
View File
@@ -0,0 +1,15 @@
"""Liveness endpoint. The shape every other route module copies."""
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class Health(BaseModel):
status: str
@router.get("/health", response_model=Health)
def health() -> Health:
return Health(status="ok")
+21
View File
@@ -0,0 +1,21 @@
"""One demo resource. Its rows mirror the frontend seed, so Preview and the
deployed app render the same screen. Replace both together."""
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class Item(BaseModel):
id: int
name: str
status: str
@router.get("/items", response_model=list[Item])
def list_items() -> list[Item]:
return [
Item(id=1, name="Draft the launch checklist", status="in progress"),
Item(id=2, name="Review onboarding copy", status="todo"),
]
+140
View File
@@ -0,0 +1,140 @@
"""Wallet entry routes."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import User, WalletEntry, EntryType
from app.schemas import (
WalletEntryCreate,
WalletEntryUpdate,
WalletEntryResponse,
DashboardMetrics
)
from app.auth import get_current_user
router = APIRouter(prefix="/wallet", tags=["wallet"])
@router.post("/entries", response_model=WalletEntryResponse)
def create_entry(
entry_data: WalletEntryCreate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Create a new wallet entry."""
new_entry = WalletEntry(
user_id=current_user.id,
amount=entry_data.amount,
entry_type=entry_data.entry_type,
description=entry_data.description
)
db.add(new_entry)
db.commit()
db.refresh(new_entry)
return new_entry
@router.get("/entries", response_model=list[WalletEntryResponse])
def get_entries(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db),
skip: int = 0,
limit: int = 100
):
"""Get all wallet entries for the current user."""
entries = db.query(WalletEntry).filter(
WalletEntry.user_id == current_user.id
).order_by(WalletEntry.created_at.desc()).offset(skip).limit(limit).all()
return entries
@router.get("/entries/{entry_id}", response_model=WalletEntryResponse)
def get_entry(
entry_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get a specific wallet entry."""
entry = db.query(WalletEntry).filter(
(WalletEntry.id == entry_id) & (WalletEntry.user_id == current_user.id)
).first()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Entry not found"
)
return entry
@router.put("/entries/{entry_id}", response_model=WalletEntryResponse)
def update_entry(
entry_id: int,
entry_data: WalletEntryUpdate,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Update a wallet entry."""
entry = db.query(WalletEntry).filter(
(WalletEntry.id == entry_id) & (WalletEntry.user_id == current_user.id)
).first()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Entry not found"
)
if entry_data.amount is not None:
entry.amount = entry_data.amount
if entry_data.entry_type is not None:
entry.entry_type = entry_data.entry_type
if entry_data.description is not None:
entry.description = entry_data.description
db.commit()
db.refresh(entry)
return entry
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_entry(
entry_id: int,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Delete a wallet entry."""
entry = db.query(WalletEntry).filter(
(WalletEntry.id == entry_id) & (WalletEntry.user_id == current_user.id)
).first()
if not entry:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Entry not found"
)
db.delete(entry)
db.commit()
@router.get("/metrics", response_model=DashboardMetrics)
def get_metrics(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Get dashboard metrics for the current user."""
entries = db.query(WalletEntry).filter(
WalletEntry.user_id == current_user.id
).all()
total_income = sum(e.amount for e in entries if e.entry_type == EntryType.INCOME)
total_expenses = sum(e.amount for e in entries if e.entry_type == EntryType.EXPENSE)
total_balance = total_income - total_expenses
return {
"total_balance": total_balance,
"total_income": total_income,
"total_expenses": total_expenses,
"entry_count": len(entries)
}
+79
View File
@@ -0,0 +1,79 @@
"""Pydantic schemas for request/response validation."""
from datetime import datetime
from pydantic import BaseModel
from typing import Optional
from enum import Enum
class EntryType(str, Enum):
"""Type of wallet entry."""
INCOME = "income"
EXPENSE = "expense"
class UserRegister(BaseModel):
"""Schema for user registration."""
username: str
email: str
password: str
class UserLogin(BaseModel):
"""Schema for user login."""
username: str
password: str
class UserResponse(BaseModel):
"""Schema for user response."""
id: int
username: str
email: str
created_at: datetime
class Config:
from_attributes = True
class WalletEntryCreate(BaseModel):
"""Schema for creating a wallet entry."""
amount: float
entry_type: EntryType
description: str
class WalletEntryUpdate(BaseModel):
"""Schema for updating a wallet entry."""
amount: Optional[float] = None
entry_type: Optional[EntryType] = None
description: Optional[str] = None
class WalletEntryResponse(BaseModel):
"""Schema for wallet entry response."""
id: int
user_id: int
amount: float
entry_type: EntryType
description: str
created_at: datetime
updated_at: datetime
class Config:
from_attributes = True
class DashboardMetrics(BaseModel):
"""Schema for dashboard metrics."""
total_balance: float
total_income: float
total_expenses: float
entry_count: int
class TokenResponse(BaseModel):
"""Schema for token response."""
access_token: str
token_type: str
user: UserResponse
+8
View File
@@ -0,0 +1,8 @@
# Exact pins — this file is the version contract for the backend.
fastapi==0.115.6
uvicorn[standard]==0.34.0
pydantic==2.10.4
sqlalchemy==2.0.36
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
+45
View File
@@ -0,0 +1,45 @@
"""Seed demo user for authentication testing."""
import os
from sqlalchemy.orm import Session
from app.database import SessionLocal, Base, engine
from app.models import User
from app.auth import hash_password
# Create tables
Base.metadata.create_all(bind=engine)
# Get demo credentials from environment
DEMO_EMAIL = os.getenv("DEMO_EMAIL", "demo@example.com")
DEMO_PASSWORD = os.getenv("DEMO_PASSWORD", "demo123")
DEMO_USERNAME = "demo"
def seed_demo_user():
"""Create demo user idempotently."""
db: Session = SessionLocal()
try:
# Check if demo user already exists
existing_user = db.query(User).filter(User.username == DEMO_USERNAME).first()
if existing_user:
# Update password if it exists
existing_user.hashed_password = hash_password(DEMO_PASSWORD)
db.commit()
print(f"Updated demo user: {DEMO_USERNAME}")
else:
# Create new demo user
demo_user = User(
username=DEMO_USERNAME,
email=DEMO_EMAIL,
hashed_password=hash_password(DEMO_PASSWORD)
)
db.add(demo_user)
db.commit()
print(f"Created demo user: {DEMO_USERNAME}")
finally:
db.close()
if __name__ == "__main__":
seed_demo_user()