Compare commits

..

2 Commits

Author SHA1 Message Date
Nox (OpenClaw) 21339d771d feat: backend core — models, auth, CRUD, tests 2026-03-17 16:16:08 +00:00
Nox (OpenClaw) d8c2048a9b chore: initial project setup
Phase 0 — full project scaffold with:
- Backend: FastAPI + SQLAlchemy 2.0 async + Alembic + PostgreSQL 16
- Frontend: React 18 + TypeScript + Vite + Tailwind CSS + shadcn/ui
- Docker Compose (prod + dev override with hot-reload)
- Health endpoint, CORS config, API proxy, env template

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-17 15:20:50 +00:00
63 changed files with 2911 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# ── PostgreSQL ──────────────────────────────────────────
POSTGRES_USER=budget
POSTGRES_PASSWORD=budget
POSTGRES_DB=budget_tracker
DB_PORT=5432
# ── Backend (FastAPI) ──────────────────────────────────
DATABASE_URL=postgresql+asyncpg://budget:budget@db:5432/budget_tracker
SECRET_KEY=change-me-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
DEBUG=false
BACKEND_PORT=8000
# ── Frontend ───────────────────────────────────────────
FRONTEND_PORT=3000
+32
View File
@@ -0,0 +1,32 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
.venv/
venv/
# Node
node_modules/
frontend/dist/
# Environment
.env
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker
pgdata/
# Testing
.coverage
htmlcov/
.pytest_cache/
+37
View File
@@ -0,0 +1,37 @@
# Budget Tracker
Application web de suivi de budget personnel.
## Stack technique
- **Backend** : FastAPI, SQLAlchemy 2.0 async, Alembic, PostgreSQL 16
- **Frontend** : React 18, TypeScript, Vite, Tailwind CSS, shadcn/ui
- **Graphiques** : Recharts
- **Export** : CSV, PDF (WeasyPrint)
## Démarrage rapide
```bash
# Copier et configurer les variables d'environnement
cp .env.example .env
# Lancer tous les services (dev avec hot-reload)
docker compose up
# Backend : http://localhost:8000
# Frontend : http://localhost:5173
# Health check : http://localhost:8000/health
```
## Développement
```bash
# Linting backend
cd backend && ruff check .
# Tests backend
cd backend && pytest
# Format frontend
cd frontend && npm run format
```
+26
View File
@@ -0,0 +1,26 @@
FROM python:3.12-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# System deps for WeasyPrint
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
libffi-dev \
libcairo2 \
libglib2.0-0 && \
rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+36
View File
@@ -0,0 +1,36 @@
[alembic]
script_location = alembic
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
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
+58
View File
@@ -0,0 +1,58 @@
import asyncio
import os
from logging.config import fileConfig
from alembic import context
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import create_async_engine
import app.models # noqa: F401 — register all models with Base.metadata
from app.database import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def get_database_url() -> str:
return os.environ.get(
"DATABASE_URL",
"postgresql+asyncpg://budget:budget@db:5432/budget_tracker",
)
def run_migrations_offline() -> None:
url = get_database_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): # noqa: ANN001
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = create_async_engine(
get_database_url(),
poolclass=pool.NullPool,
)
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())
+26
View File
@@ -0,0 +1,26 @@
"""${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, 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:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,163 @@
"""initial models
Revision ID: 001_initial
Revises:
Create Date: 2026-03-17
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "001_initial"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
# --- users ---
op.create_table(
"users",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("hashed_password", sa.String(255), nullable=False),
sa.Column("full_name", sa.String(100), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
sa.Column(
"created_at",
sa.DateTime(timezone=False),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=False),
nullable=False,
server_default=sa.text("now()"),
),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("email"),
)
op.create_index("ix_users_email", "users", ["email"])
# --- categories ---
op.create_table(
"categories",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(50), nullable=False),
sa.Column("type", sa.String(10), nullable=False),
sa.Column("color", sa.String(7), nullable=True),
sa.Column("icon", sa.String(50), nullable=True),
sa.Column("is_default", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("deleted_at", sa.DateTime(timezone=False), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=False),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_categories_user_id", "categories", ["user_id"])
# --- transactions ---
op.create_table(
"transactions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("category_id", sa.Uuid(), nullable=False),
sa.Column("amount_cents", sa.Integer(), nullable=False),
sa.Column("type", sa.String(10), nullable=False),
sa.Column("description", sa.String(255), nullable=True),
sa.Column("transaction_date", sa.Date(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=False), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=False),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=False),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint("amount_cents > 0", name="ck_transactions_amount_positive"),
sa.ForeignKeyConstraint(["category_id"], ["categories.id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
"ix_transactions_user_date",
"transactions",
["user_id", "transaction_date", "deleted_at"],
)
op.create_index(
"ix_transactions_user_category",
"transactions",
["user_id", "category_id", "deleted_at"],
)
# --- budgets ---
op.create_table(
"budgets",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("category_id", sa.Uuid(), nullable=False),
sa.Column("month", sa.String(7), nullable=False),
sa.Column("limit_cents", sa.Integer(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=False),
nullable=False,
server_default=sa.text("now()"),
),
sa.Column(
"updated_at",
sa.DateTime(timezone=False),
nullable=False,
server_default=sa.text("now()"),
),
sa.CheckConstraint("limit_cents > 0", name="ck_budgets_limit_positive"),
sa.ForeignKeyConstraint(["category_id"], ["categories.id"]),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint(
"user_id", "category_id", "month", name="uq_budgets_user_category_month"
),
)
op.create_index("ix_budgets_user_id", "budgets", ["user_id"])
# --- refresh_tokens ---
op.create_table(
"refresh_tokens",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("token_hash", sa.String(64), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=False), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=False), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=False),
nullable=False,
server_default=sa.text("now()"),
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("token_hash"),
)
op.create_index("ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"])
def downgrade() -> None:
op.drop_table("refresh_tokens")
op.drop_table("budgets")
op.drop_table("transactions")
op.drop_table("categories")
op.drop_table("users")
View File
View File
+38
View File
@@ -0,0 +1,38 @@
import uuid
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.security import decode_token
from app.database import get_session
from app.models.user import User
bearer_scheme = HTTPBearer()
async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
session: AsyncSession = Depends(get_session),
) -> User:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = decode_token(credentials.credentials)
user_id_str: str | None = payload.get("sub")
if user_id_str is None:
raise credentials_exception
user_id = uuid.UUID(user_id_str)
except (JWTError, ValueError):
raise credentials_exception
result = await session.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if user is None or not user.is_active:
raise credentials_exception
return user
+146
View File
@@ -0,0 +1,146 @@
import uuid
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from jose import JWTError
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.auth.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
hash_token,
verify_password,
)
from app.config import settings
from app.database import get_session
from app.models.refresh_token import RefreshToken
from app.models.user import User
from app.schemas.auth import Token, TokenRefresh, TokenRefreshRequest, UserCreate, UserResponse
from app.services.category_service import create_default_categories
from app.utils import utcnow
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
session: AsyncSession = Depends(get_session),
) -> UserResponse:
existing = await session.execute(select(User).where(User.email == user_data.email))
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
user = User(
email=user_data.email,
hashed_password=hash_password(user_data.password),
full_name=user_data.full_name,
)
session.add(user)
await session.flush() # Populate user.id before creating categories
await create_default_categories(session, user.id)
await session.commit()
await session.refresh(user)
return UserResponse.model_validate(user)
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
session: AsyncSession = Depends(get_session),
) -> Token:
result = await session.execute(select(User).where(User.email == form_data.username))
user = result.scalar_one_or_none()
if user is None or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
if not user.is_active:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account inactive")
access_token = create_access_token({"sub": str(user.id)})
refresh_token_str = create_refresh_token({"sub": str(user.id)})
token_entry = RefreshToken(
user_id=user.id,
token_hash=hash_token(refresh_token_str),
expires_at=utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS),
)
session.add(token_entry)
await session.commit()
return Token(
access_token=access_token,
refresh_token=refresh_token_str,
token_type="bearer",
)
@router.post("/refresh", response_model=TokenRefresh)
async def refresh_token(
data: TokenRefreshRequest,
session: AsyncSession = Depends(get_session),
) -> TokenRefresh:
try:
payload = decode_token(data.refresh_token)
user_id_str: str | None = payload.get("sub")
if not user_id_str:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
)
user_id = uuid.UUID(user_id_str)
except (JWTError, ValueError):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token"
)
token_hash = hash_token(data.refresh_token)
result = await session.execute(
select(RefreshToken).where(
RefreshToken.token_hash == token_hash,
RefreshToken.revoked_at.is_(None),
)
)
token_entry = result.scalar_one_or_none()
if token_entry is None or token_entry.expires_at < utcnow():
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="Refresh token expired or revoked"
)
# Revoke consumed token (rotation)
token_entry.revoked_at = utcnow()
await session.commit()
new_access_token = create_access_token({"sub": str(user_id)})
return TokenRefresh(access_token=new_access_token, token_type="bearer")
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
data: TokenRefreshRequest,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> None:
token_hash = hash_token(data.refresh_token)
result = await session.execute(
select(RefreshToken).where(
RefreshToken.token_hash == token_hash,
RefreshToken.user_id == current_user.id,
)
)
token_entry = result.scalar_one_or_none()
if token_entry is not None:
token_entry.revoked_at = utcnow()
await session.commit()
+46
View File
@@ -0,0 +1,46 @@
import hashlib
import uuid
from datetime import timedelta
from typing import Any
from jose import jwt
from passlib.context import CryptContext
from app.config import settings
from app.utils import utcnow
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
ALGORITHM = "HS256"
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict[str, Any]) -> str:
to_encode = data.copy()
to_encode["exp"] = utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def create_refresh_token(data: dict[str, Any]) -> str:
to_encode = data.copy()
to_encode["exp"] = utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS)
# jti ensures each token is unique even when issued for the same user at the same time
to_encode["jti"] = str(uuid.uuid4())
return jwt.encode(to_encode, settings.SECRET_KEY, algorithm=ALGORITHM)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and verify a JWT token. Raises jose.JWTError on failure."""
return jwt.decode(token, settings.SECRET_KEY, algorithms=[ALGORITHM])
def hash_token(token: str) -> str:
"""Return SHA-256 hex digest of a token for safe storage."""
return hashlib.sha256(token.encode()).hexdigest()
+17
View File
@@ -0,0 +1,17 @@
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
"""Application settings loaded from environment variables."""
DATABASE_URL: str = "postgresql+asyncpg://budget:budget@db:5432/budget_tracker"
SECRET_KEY: str = "change-me-in-production"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
REFRESH_TOKEN_EXPIRE_DAYS: int = 7
CORS_ORIGINS: list[str] = ["http://localhost:5173"]
DEBUG: bool = False
model_config = {"env_file": ".env", "env_file_encoding": "utf-8"}
settings = Settings()
+31
View File
@@ -0,0 +1,31 @@
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_pre_ping=True,
)
async_session_factory = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
yield session
+47
View File
@@ -0,0 +1,47 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.auth.router import router as auth_router
from app.config import settings
from app.database import engine
from app.routers.categories import router as categories_router
from app.routers.transactions import router as transactions_router
@asynccontextmanager
async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
yield
await engine.dispose()
def create_app() -> FastAPI:
app = FastAPI(
title="Budget Tracker API",
version="0.1.0",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health")
async def health_check() -> dict[str, str]:
return {"status": "ok"}
api_prefix = "/api/v1"
app.include_router(auth_router, prefix=api_prefix)
app.include_router(transactions_router, prefix=api_prefix)
app.include_router(categories_router, prefix=api_prefix)
return app
app = create_app()
+16
View File
@@ -0,0 +1,16 @@
# Import all models so Alembic can discover them for autogenerate
from app.models.budget import Budget
from app.models.category import Category, CategoryType
from app.models.refresh_token import RefreshToken
from app.models.transaction import Transaction, TransactionType
from app.models.user import User
__all__ = [
"User",
"Category",
"CategoryType",
"Transaction",
"TransactionType",
"Budget",
"RefreshToken",
]
+47
View File
@@ -0,0 +1,47 @@
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
CheckConstraint,
DateTime,
ForeignKey,
Integer,
String,
UniqueConstraint,
Uuid,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
if TYPE_CHECKING:
from app.models.category import Category
class Budget(Base):
__tablename__ = "budgets"
__table_args__ = (
UniqueConstraint("user_id", "category_id", "month", name="uq_budgets_user_category_month"),
CheckConstraint("limit_cents > 0", name="ck_budgets_limit_positive"),
)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
category_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("categories.id"), nullable=False
)
# Format YYYY-MM
month: Mapped[str] = mapped_column(String(7), nullable=False)
limit_cents: Mapped[int] = mapped_column(Integer, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, server_default=func.now()
)
category: Mapped["Category"] = relationship("Category", lazy="raise")
+36
View File
@@ -0,0 +1,36 @@
import enum
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Enum, ForeignKey, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class CategoryType(str, enum.Enum):
income = "income"
expense = "expense"
class Category(Base):
__tablename__ = "categories"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
name: Mapped[str] = mapped_column(String(50), nullable=False)
type: Mapped[CategoryType] = mapped_column(
Enum(CategoryType, name="categorytype", native_enum=False, length=10),
nullable=False,
)
color: Mapped[str | None] = mapped_column(String(7), nullable=True)
icon: Mapped[str | None] = mapped_column(String(50), nullable=True)
is_default: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default="false"
)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, server_default=func.now()
)
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class RefreshToken(Base):
__tablename__ = "refresh_tokens"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# SHA-256 hash of the raw token (never store raw tokens)
token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=False), nullable=False)
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, server_default=func.now()
)
+62
View File
@@ -0,0 +1,62 @@
import enum
import uuid
from datetime import date, datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
CheckConstraint,
Date,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Uuid,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base
if TYPE_CHECKING:
from app.models.category import Category
class TransactionType(str, enum.Enum):
income = "income"
expense = "expense"
class Transaction(Base):
__tablename__ = "transactions"
__table_args__ = (
CheckConstraint("amount_cents > 0", name="ck_transactions_amount_positive"),
Index("ix_transactions_user_date", "user_id", "transaction_date", "deleted_at"),
Index("ix_transactions_user_category", "user_id", "category_id", "deleted_at"),
)
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
category_id: Mapped[uuid.UUID] = mapped_column(
Uuid, ForeignKey("categories.id"), nullable=False
)
amount_cents: Mapped[int] = mapped_column(Integer, nullable=False)
type: Mapped[TransactionType] = mapped_column(
Enum(TransactionType, name="transactiontype", native_enum=False, length=10),
nullable=False,
)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
transaction_date: Mapped[date] = mapped_column(Date, nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=False), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, server_default=func.now()
)
# Relationships — use selectinload() explicitly; lazy="raise" prevents accidental N+1
category: Mapped["Category"] = relationship("Category", lazy="raise")
+23
View File
@@ -0,0 +1,23 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, Uuid, func
from sqlalchemy.orm import Mapped, mapped_column
from app.database import Base
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
full_name: Mapped[str] = mapped_column(String(100), nullable=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default="true")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=False), nullable=False, server_default=func.now()
)
View File
+53
View File
@@ -0,0 +1,53 @@
import uuid
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.database import get_session
from app.models.category import CategoryType
from app.models.user import User
from app.schemas.category import CategoryCreate, CategoryResponse, CategoryUpdate
from app.services import category_service
router = APIRouter(prefix="/categories", tags=["categories"])
@router.get("", response_model=list[CategoryResponse])
async def list_categories(
type: CategoryType | None = None,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> list[CategoryResponse]:
cats = await category_service.list_categories(session, current_user.id, type_filter=type)
return [CategoryResponse.model_validate(c) for c in cats]
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
async def create_category(
data: CategoryCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> CategoryResponse:
cat = await category_service.create_category(session, current_user.id, data)
return CategoryResponse.model_validate(cat)
@router.put("/{category_id}", response_model=CategoryResponse)
async def update_category(
category_id: uuid.UUID,
data: CategoryUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> CategoryResponse:
cat = await category_service.update_category(session, current_user.id, category_id, data)
return CategoryResponse.model_validate(cat)
@router.delete("/{category_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_category(
category_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> None:
await category_service.delete_category(session, current_user.id, category_id)
+87
View File
@@ -0,0 +1,87 @@
import uuid
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.database import get_session
from app.models.transaction import TransactionType
from app.models.user import User
from app.schemas.transaction import (
PaginatedTransactions,
TransactionCreate,
TransactionResponse,
TransactionUpdate,
)
from app.services import transaction_service
router = APIRouter(prefix="/transactions", tags=["transactions"])
@router.get("", response_model=PaginatedTransactions)
async def list_transactions(
month: str | None = Query(None, description="Filter by month (YYYY-MM)"),
category_id: uuid.UUID | None = Query(None),
type: TransactionType | None = Query(None),
page: int = Query(1, ge=1),
per_page: int = Query(20, ge=1, le=100),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> PaginatedTransactions:
items, total = await transaction_service.list_transactions(
session,
current_user.id,
month=month,
category_id=category_id,
type_filter=type,
page=page,
per_page=per_page,
)
return PaginatedTransactions(
items=[TransactionResponse.model_validate(t) for t in items],
total=total,
page=page,
per_page=per_page,
)
@router.post("", response_model=TransactionResponse, status_code=status.HTTP_201_CREATED)
async def create_transaction(
data: TransactionCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> TransactionResponse:
tx = await transaction_service.create_transaction(session, current_user.id, data)
return TransactionResponse.model_validate(tx)
@router.get("/{transaction_id}", response_model=TransactionResponse)
async def get_transaction(
transaction_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> TransactionResponse:
tx = await transaction_service.get_transaction(session, current_user.id, transaction_id)
return TransactionResponse.model_validate(tx)
@router.put("/{transaction_id}", response_model=TransactionResponse)
async def update_transaction(
transaction_id: uuid.UUID,
data: TransactionUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> TransactionResponse:
tx = await transaction_service.update_transaction(
session, current_user.id, transaction_id, data
)
return TransactionResponse.model_validate(tx)
@router.delete("/{transaction_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_transaction(
transaction_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> None:
await transaction_service.delete_transaction(session, current_user.id, transaction_id)
View File
+32
View File
@@ -0,0 +1,32 @@
import uuid
from pydantic import BaseModel, EmailStr
class UserCreate(BaseModel):
email: EmailStr
password: str
full_name: str
class UserResponse(BaseModel):
id: uuid.UUID
email: str
full_name: str
model_config = {"from_attributes": True}
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenRefresh(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenRefreshRequest(BaseModel):
refresh_token: str
+31
View File
@@ -0,0 +1,31 @@
import uuid
from datetime import datetime
from pydantic import BaseModel
from app.models.category import CategoryType
class CategoryCreate(BaseModel):
name: str
type: CategoryType
color: str | None = None
icon: str | None = None
class CategoryUpdate(BaseModel):
name: str | None = None
color: str | None = None
icon: str | None = None
class CategoryResponse(BaseModel):
id: uuid.UUID
name: str
type: CategoryType
color: str | None
icon: str | None
is_default: bool
created_at: datetime
model_config = {"from_attributes": True}
+12
View File
@@ -0,0 +1,12 @@
from typing import Generic, TypeVar
from pydantic import BaseModel
T = TypeVar("T")
class PaginatedResponse(BaseModel, Generic[T]):
items: list[T]
total: int
page: int
per_page: int
+60
View File
@@ -0,0 +1,60 @@
import uuid
from datetime import date, datetime
from pydantic import BaseModel, field_validator
from app.models.transaction import TransactionType
from app.schemas.common import PaginatedResponse
class CategoryBrief(BaseModel):
id: uuid.UUID
name: str
color: str | None
model_config = {"from_attributes": True}
class TransactionCreate(BaseModel):
amount_cents: int
type: TransactionType
category_id: uuid.UUID
description: str | None = None
transaction_date: date
@field_validator("amount_cents")
@classmethod
def amount_must_be_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("amount_cents must be greater than 0")
return v
class TransactionUpdate(BaseModel):
amount_cents: int | None = None
type: TransactionType | None = None
category_id: uuid.UUID | None = None
description: str | None = None
transaction_date: date | None = None
@field_validator("amount_cents")
@classmethod
def amount_must_be_positive(cls, v: int | None) -> int | None:
if v is not None and v <= 0:
raise ValueError("amount_cents must be greater than 0")
return v
class TransactionResponse(BaseModel):
id: uuid.UUID
amount_cents: int
type: TransactionType
description: str | None
category: CategoryBrief
transaction_date: date
created_at: datetime
model_config = {"from_attributes": True}
PaginatedTransactions = PaginatedResponse[TransactionResponse]
View File
+134
View File
@@ -0,0 +1,134 @@
import uuid
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.category import Category, CategoryType
from app.models.transaction import Transaction
from app.schemas.category import CategoryCreate, CategoryUpdate
from app.utils import utcnow
# Default categories created at registration
_DEFAULT_CATEGORIES = [
{"name": "Alimentation", "type": CategoryType.expense, "color": "#22c55e", "icon": "utensils"},
{"name": "Transport", "type": CategoryType.expense, "color": "#3b82f6", "icon": "car"},
{"name": "Logement", "type": CategoryType.expense, "color": "#f59e0b", "icon": "home"},
{"name": "Santé", "type": CategoryType.expense, "color": "#ef4444", "icon": "heart-pulse"},
{"name": "Loisirs", "type": CategoryType.expense, "color": "#a855f7", "icon": "gamepad-2"},
{"name": "Divers", "type": CategoryType.expense, "color": "#6b7280", "icon": "package"},
{"name": "Salaire", "type": CategoryType.income, "color": "#10b981", "icon": "briefcase"},
{"name": "Freelance", "type": CategoryType.income, "color": "#06b6d4", "icon": "laptop"},
{"name": "Remboursement", "type": CategoryType.income, "color": "#8b5cf6", "icon": "refresh-cw"},
]
async def create_default_categories(
session: AsyncSession, user_id: uuid.UUID
) -> list[Category]:
"""Create the default categories for a newly registered user."""
categories = []
for data in _DEFAULT_CATEGORIES:
cat = Category(user_id=user_id, is_default=True, **data)
session.add(cat)
categories.append(cat)
await session.flush()
return categories
async def list_categories(
session: AsyncSession,
user_id: uuid.UUID,
*,
type_filter: CategoryType | None = None,
) -> list[Category]:
query = select(Category).where(
Category.user_id == user_id,
Category.deleted_at.is_(None),
)
if type_filter is not None:
query = query.where(Category.type == type_filter)
query = query.order_by(Category.name)
result = await session.execute(query)
return list(result.scalars().all())
async def get_category(
session: AsyncSession,
user_id: uuid.UUID,
category_id: uuid.UUID,
) -> Category:
result = await session.execute(
select(Category).where(
Category.id == category_id,
Category.user_id == user_id,
Category.deleted_at.is_(None),
)
)
cat = result.scalar_one_or_none()
if cat is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
return cat
async def create_category(
session: AsyncSession,
user_id: uuid.UUID,
data: CategoryCreate,
) -> Category:
cat = Category(
user_id=user_id,
name=data.name,
type=data.type,
color=data.color,
icon=data.icon,
is_default=False,
)
session.add(cat)
await session.commit()
await session.refresh(cat)
return cat
async def update_category(
session: AsyncSession,
user_id: uuid.UUID,
category_id: uuid.UUID,
data: CategoryUpdate,
) -> Category:
cat = await get_category(session, user_id, category_id)
if data.name is not None:
cat.name = data.name
if data.color is not None:
cat.color = data.color
if data.icon is not None:
cat.icon = data.icon
await session.commit()
await session.refresh(cat)
return cat
async def delete_category(
session: AsyncSession,
user_id: uuid.UUID,
category_id: uuid.UUID,
) -> None:
cat = await get_category(session, user_id, category_id)
# Refuse deletion if active transactions exist
count_result = await session.execute(
select(func.count()).where(
Transaction.category_id == category_id,
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
)
)
active_count = count_result.scalar_one()
if active_count > 0:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Cannot delete category: {active_count} active transaction(s) are linked to it",
)
cat.deleted_at = utcnow()
await session.commit()
@@ -0,0 +1,134 @@
> import uuid
> from fastapi import HTTPException, status
> from sqlalchemy import func, select
> from sqlalchemy.ext.asyncio import AsyncSession
> from app.models.category import Category, CategoryType
> from app.models.transaction import Transaction
> from app.schemas.category import CategoryCreate, CategoryUpdate
> from app.utils import utcnow
# Default categories created at registration
> _DEFAULT_CATEGORIES = [
> {"name": "Alimentation", "type": CategoryType.expense, "color": "#22c55e", "icon": "utensils"},
> {"name": "Transport", "type": CategoryType.expense, "color": "#3b82f6", "icon": "car"},
> {"name": "Logement", "type": CategoryType.expense, "color": "#f59e0b", "icon": "home"},
> {"name": "Santé", "type": CategoryType.expense, "color": "#ef4444", "icon": "heart-pulse"},
> {"name": "Loisirs", "type": CategoryType.expense, "color": "#a855f7", "icon": "gamepad-2"},
> {"name": "Divers", "type": CategoryType.expense, "color": "#6b7280", "icon": "package"},
> {"name": "Salaire", "type": CategoryType.income, "color": "#10b981", "icon": "briefcase"},
> {"name": "Freelance", "type": CategoryType.income, "color": "#06b6d4", "icon": "laptop"},
> {"name": "Remboursement", "type": CategoryType.income, "color": "#8b5cf6", "icon": "refresh-cw"},
> ]
> async def create_default_categories(
> session: AsyncSession, user_id: uuid.UUID
> ) -> list[Category]:
> """Create the default categories for a newly registered user."""
> categories = []
> for data in _DEFAULT_CATEGORIES:
> cat = Category(user_id=user_id, is_default=True, **data)
> session.add(cat)
> categories.append(cat)
> await session.flush()
! return categories
> async def list_categories(
> session: AsyncSession,
> user_id: uuid.UUID,
> *,
> type_filter: CategoryType | None = None,
> ) -> list[Category]:
> query = select(Category).where(
> Category.user_id == user_id,
> Category.deleted_at.is_(None),
> )
> if type_filter is not None:
> query = query.where(Category.type == type_filter)
> query = query.order_by(Category.name)
> result = await session.execute(query)
! return list(result.scalars().all())
> async def get_category(
> session: AsyncSession,
> user_id: uuid.UUID,
> category_id: uuid.UUID,
> ) -> Category:
> result = await session.execute(
> select(Category).where(
> Category.id == category_id,
> Category.user_id == user_id,
> Category.deleted_at.is_(None),
> )
> )
! cat = result.scalar_one_or_none()
! if cat is None:
! raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Category not found")
! return cat
> async def create_category(
> session: AsyncSession,
> user_id: uuid.UUID,
> data: CategoryCreate,
> ) -> Category:
> cat = Category(
> user_id=user_id,
> name=data.name,
> type=data.type,
> color=data.color,
> icon=data.icon,
> is_default=False,
> )
> session.add(cat)
> await session.commit()
! await session.refresh(cat)
! return cat
> async def update_category(
> session: AsyncSession,
> user_id: uuid.UUID,
> category_id: uuid.UUID,
> data: CategoryUpdate,
> ) -> Category:
> cat = await get_category(session, user_id, category_id)
! if data.name is not None:
! cat.name = data.name
! if data.color is not None:
! cat.color = data.color
! if data.icon is not None:
! cat.icon = data.icon
! await session.commit()
! await session.refresh(cat)
! return cat
> async def delete_category(
> session: AsyncSession,
> user_id: uuid.UUID,
> category_id: uuid.UUID,
> ) -> None:
> cat = await get_category(session, user_id, category_id)
# Refuse deletion if active transactions exist
! count_result = await session.execute(
! select(func.count()).where(
! Transaction.category_id == category_id,
! Transaction.user_id == user_id,
! Transaction.deleted_at.is_(None),
! )
! )
! active_count = count_result.scalar_one()
! if active_count > 0:
! raise HTTPException(
! status_code=status.HTTP_409_CONFLICT,
! detail=f"Cannot delete category: {active_count} active transaction(s) are linked to it",
! )
! cat.deleted_at = utcnow()
! await session.commit()
+152
View File
@@ -0,0 +1,152 @@
import uuid
from datetime import date
from fastapi import HTTPException, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.transaction import Transaction, TransactionType
from app.schemas.transaction import TransactionCreate, TransactionUpdate
from app.utils import utcnow
def _month_bounds(month: str) -> tuple[date, date]:
"""Return (start_inclusive, end_exclusive) date bounds for a YYYY-MM string."""
year, mon = map(int, month.split("-"))
start = date(year, mon, 1)
if mon == 12:
end = date(year + 1, 1, 1)
else:
end = date(year, mon + 1, 1)
return start, end
async def create_transaction(
session: AsyncSession,
user_id: uuid.UUID,
data: TransactionCreate,
) -> Transaction:
tx = Transaction(
user_id=user_id,
category_id=data.category_id,
amount_cents=data.amount_cents,
type=data.type,
description=data.description,
transaction_date=data.transaction_date,
)
session.add(tx)
await session.commit()
# Reload with category relationship
result = await session.execute(
select(Transaction)
.options(selectinload(Transaction.category))
.where(Transaction.id == tx.id)
)
return result.scalar_one()
async def list_transactions(
session: AsyncSession,
user_id: uuid.UUID,
*,
month: str | None = None,
category_id: uuid.UUID | None = None,
type_filter: TransactionType | None = None,
page: int = 1,
per_page: int = 20,
) -> tuple[list[Transaction], int]:
base_query = select(Transaction).where(
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
)
if month is not None:
start, end = _month_bounds(month)
base_query = base_query.where(
Transaction.transaction_date >= start,
Transaction.transaction_date < end,
)
if category_id is not None:
base_query = base_query.where(Transaction.category_id == category_id)
if type_filter is not None:
base_query = base_query.where(Transaction.type == type_filter)
# Total count (before pagination)
count_result = await session.execute(
select(func.count()).select_from(base_query.subquery())
)
total = count_result.scalar_one()
# Paginated rows with category eager-loaded
items_query = (
base_query.options(selectinload(Transaction.category))
.order_by(Transaction.transaction_date.desc(), Transaction.created_at.desc())
.offset((page - 1) * per_page)
.limit(per_page)
)
result = await session.execute(items_query)
return list(result.scalars().all()), total
async def get_transaction(
session: AsyncSession,
user_id: uuid.UUID,
transaction_id: uuid.UUID,
) -> Transaction:
result = await session.execute(
select(Transaction)
.options(selectinload(Transaction.category))
.where(
Transaction.id == transaction_id,
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
)
)
tx = result.scalar_one_or_none()
if tx is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Transaction not found")
return tx
async def update_transaction(
session: AsyncSession,
user_id: uuid.UUID,
transaction_id: uuid.UUID,
data: TransactionUpdate,
) -> Transaction:
tx = await get_transaction(session, user_id, transaction_id)
if data.amount_cents is not None:
tx.amount_cents = data.amount_cents
if data.type is not None:
tx.type = data.type
if data.category_id is not None:
tx.category_id = data.category_id
if data.description is not None:
tx.description = data.description
if data.transaction_date is not None:
tx.transaction_date = data.transaction_date
tx.updated_at = utcnow()
await session.commit()
# Reload with fresh category
result = await session.execute(
select(Transaction)
.options(selectinload(Transaction.category))
.where(Transaction.id == tx.id)
)
return result.scalar_one()
async def delete_transaction(
session: AsyncSession,
user_id: uuid.UUID,
transaction_id: uuid.UUID,
) -> None:
tx = await get_transaction(session, user_id, transaction_id)
tx.deleted_at = utcnow()
tx.updated_at = utcnow()
await session.commit()
@@ -0,0 +1,152 @@
> import uuid
> from datetime import date
> from fastapi import HTTPException, status
> from sqlalchemy import func, select
> from sqlalchemy.ext.asyncio import AsyncSession
> from sqlalchemy.orm import selectinload
> from app.models.transaction import Transaction, TransactionType
> from app.schemas.transaction import TransactionCreate, TransactionUpdate
> from app.utils import utcnow
> def _month_bounds(month: str) -> tuple[date, date]:
> """Return (start_inclusive, end_exclusive) date bounds for a YYYY-MM string."""
> year, mon = map(int, month.split("-"))
> start = date(year, mon, 1)
> if mon == 12:
! end = date(year + 1, 1, 1)
> else:
> end = date(year, mon + 1, 1)
> return start, end
> async def create_transaction(
> session: AsyncSession,
> user_id: uuid.UUID,
> data: TransactionCreate,
> ) -> Transaction:
> tx = Transaction(
> user_id=user_id,
> category_id=data.category_id,
> amount_cents=data.amount_cents,
> type=data.type,
> description=data.description,
> transaction_date=data.transaction_date,
> )
> session.add(tx)
> await session.commit()
# Reload with category relationship
! result = await session.execute(
! select(Transaction)
! .options(selectinload(Transaction.category))
! .where(Transaction.id == tx.id)
! )
! return result.scalar_one()
> async def list_transactions(
> session: AsyncSession,
> user_id: uuid.UUID,
> *,
> month: str | None = None,
> category_id: uuid.UUID | None = None,
> type_filter: TransactionType | None = None,
> page: int = 1,
> per_page: int = 20,
> ) -> tuple[list[Transaction], int]:
> base_query = select(Transaction).where(
> Transaction.user_id == user_id,
> Transaction.deleted_at.is_(None),
> )
> if month is not None:
> start, end = _month_bounds(month)
> base_query = base_query.where(
> Transaction.transaction_date >= start,
> Transaction.transaction_date < end,
> )
> if category_id is not None:
> base_query = base_query.where(Transaction.category_id == category_id)
> if type_filter is not None:
> base_query = base_query.where(Transaction.type == type_filter)
# Total count (before pagination)
> count_result = await session.execute(
> select(func.count()).select_from(base_query.subquery())
> )
! total = count_result.scalar_one()
# Paginated rows with category eager-loaded
! items_query = (
! base_query.options(selectinload(Transaction.category))
! .order_by(Transaction.transaction_date.desc(), Transaction.created_at.desc())
! .offset((page - 1) * per_page)
! .limit(per_page)
! )
! result = await session.execute(items_query)
! return list(result.scalars().all()), total
> async def get_transaction(
> session: AsyncSession,
> user_id: uuid.UUID,
> transaction_id: uuid.UUID,
> ) -> Transaction:
> result = await session.execute(
> select(Transaction)
> .options(selectinload(Transaction.category))
> .where(
> Transaction.id == transaction_id,
> Transaction.user_id == user_id,
> Transaction.deleted_at.is_(None),
> )
> )
! tx = result.scalar_one_or_none()
! if tx is None:
! raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Transaction not found")
! return tx
> async def update_transaction(
> session: AsyncSession,
> user_id: uuid.UUID,
> transaction_id: uuid.UUID,
> data: TransactionUpdate,
> ) -> Transaction:
> tx = await get_transaction(session, user_id, transaction_id)
! if data.amount_cents is not None:
! tx.amount_cents = data.amount_cents
! if data.type is not None:
! tx.type = data.type
! if data.category_id is not None:
! tx.category_id = data.category_id
! if data.description is not None:
! tx.description = data.description
! if data.transaction_date is not None:
! tx.transaction_date = data.transaction_date
! tx.updated_at = utcnow()
! await session.commit()
# Reload with fresh category
! result = await session.execute(
! select(Transaction)
! .options(selectinload(Transaction.category))
! .where(Transaction.id == tx.id)
! )
! return result.scalar_one()
> async def delete_transaction(
> session: AsyncSession,
> user_id: uuid.UUID,
> transaction_id: uuid.UUID,
> ) -> None:
> tx = await get_transaction(session, user_id, transaction_id)
! tx.deleted_at = utcnow()
! tx.updated_at = utcnow()
! await session.commit()
+6
View File
@@ -0,0 +1,6 @@
from datetime import datetime, timezone
def utcnow() -> datetime:
"""Return current UTC datetime without timezone info (stored as naive UTC)."""
return datetime.now(timezone.utc).replace(tzinfo=None)
+41
View File
@@ -0,0 +1,41 @@
[project]
name = "budget-tracker-backend"
version = "0.1.0"
requires-python = ">=3.12"
[tool.ruff]
target-version = "py312"
line-length = 99
[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"N", # pep8-naming
"UP", # pyupgrade
"B", # flake8-bugbear
"SIM", # flake8-simplify
"TCH", # flake8-type-checking
]
[tool.ruff.lint.isort]
known-first-party = ["app"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
[tool.coverage.run]
concurrency = ["greenlet", "thread"]
source = ["app"]
[tool.coverage.report]
fail_under = 80
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"raise NotImplementedError",
]
+19
View File
@@ -0,0 +1,19 @@
fastapi==0.115.6
uvicorn[standard]==0.34.0
sqlalchemy[asyncio]==2.0.36
asyncpg==0.30.0
alembic==1.14.1
pydantic==2.10.4
pydantic-settings==2.7.1
pydantic[email]==2.10.4
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
bcrypt==4.2.1
python-multipart==0.0.20
httpx==0.28.1
weasyprint==63.1
# Test dependencies
aiosqlite==0.20.0
pytest==8.3.4
pytest-asyncio==0.25.0
pytest-cov==6.0.0
View File
+77
View File
@@ -0,0 +1,77 @@
"""
Pytest fixtures for integration tests.
Uses SQLite in-memory (aiosqlite) for speed.
Each test function gets a fresh database and HTTP client.
"""
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.database import Base, get_session
from app.main import app
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest_asyncio.fixture
async def test_engine():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest_asyncio.fixture
async def db_session(test_engine):
"""Direct database session for use in fixtures and assertions."""
session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async with session_factory() as session:
yield session
@pytest_asyncio.fixture
async def client(test_engine) -> AsyncClient:
"""HTTP client wired to a fresh in-memory database."""
session_factory = async_sessionmaker(test_engine, expire_on_commit=False)
async def override_get_session():
async with session_factory() as session:
yield session
app.dependency_overrides[get_session] = override_get_session
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Helper factories
# ---------------------------------------------------------------------------
async def create_user_and_login(
client: AsyncClient,
email: str = "user@example.com",
password: str = "password123",
full_name: str = "Test User",
) -> tuple[dict, str]:
"""Register a user and return (user_json, access_token)."""
reg_resp = await client.post(
"/api/v1/auth/register",
json={"email": email, "password": password, "full_name": full_name},
)
assert reg_resp.status_code == 201, reg_resp.text
login_resp = await client.post(
"/api/v1/auth/login",
data={"username": email, "password": password},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert login_resp.status_code == 200, login_resp.text
return reg_resp.json(), login_resp.json()["access_token"]
+177
View File
@@ -0,0 +1,177 @@
"""Tests for auth endpoints: register, login, refresh, logout."""
import pytest
from httpx import AsyncClient
from tests.conftest import create_user_and_login
@pytest.mark.asyncio
async def test_register_success(client: AsyncClient):
resp = await client.post(
"/api/v1/auth/register",
json={"email": "alice@example.com", "password": "s3cr3t", "full_name": "Alice"},
)
assert resp.status_code == 201
data = resp.json()
assert data["email"] == "alice@example.com"
assert data["full_name"] == "Alice"
assert "id" in data
assert "hashed_password" not in data
@pytest.mark.asyncio
async def test_register_duplicate_email(client: AsyncClient):
payload = {"email": "dup@example.com", "password": "pass", "full_name": "Dup"}
await client.post("/api/v1/auth/register", json=payload)
resp = await client.post("/api/v1/auth/register", json=payload)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_register_creates_default_categories(client: AsyncClient):
_, token = await create_user_and_login(client)
resp = await client.get(
"/api/v1/categories",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
cats = resp.json()
assert len(cats) >= 6
names = {c["name"] for c in cats}
assert "Alimentation" in names
assert "Salaire" in names
@pytest.mark.asyncio
async def test_login_success(client: AsyncClient):
await client.post(
"/api/v1/auth/register",
json={"email": "bob@example.com", "password": "mypass", "full_name": "Bob"},
)
resp = await client.post(
"/api/v1/auth/login",
data={"username": "bob@example.com", "password": "mypass"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 200
data = resp.json()
assert "access_token" in data
assert "refresh_token" in data
assert data["token_type"] == "bearer"
@pytest.mark.asyncio
async def test_login_wrong_password(client: AsyncClient):
await client.post(
"/api/v1/auth/register",
json={"email": "carol@example.com", "password": "correct", "full_name": "Carol"},
)
resp = await client.post(
"/api/v1/auth/login",
data={"username": "carol@example.com", "password": "wrong"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_login_unknown_email(client: AsyncClient):
resp = await client.post(
"/api/v1/auth/login",
data={"username": "nobody@example.com", "password": "x"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_access_protected_route_with_valid_token(client: AsyncClient):
_, token = await create_user_and_login(client)
resp = await client.get(
"/api/v1/categories",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
@pytest.mark.asyncio
async def test_access_protected_route_without_token(client: AsyncClient):
resp = await client.get("/api/v1/categories")
assert resp.status_code == 403 # HTTPBearer returns 403 when no credentials
@pytest.mark.asyncio
async def test_access_protected_route_invalid_token(client: AsyncClient):
resp = await client.get(
"/api/v1/categories",
headers={"Authorization": "Bearer invalidtoken"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_refresh_token(client: AsyncClient):
await client.post(
"/api/v1/auth/register",
json={"email": "dave@example.com", "password": "pass", "full_name": "Dave"},
)
login_resp = await client.post(
"/api/v1/auth/login",
data={"username": "dave@example.com", "password": "pass"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
refresh_token = login_resp.json()["refresh_token"]
resp = await client.post(
"/api/v1/auth/refresh",
json={"refresh_token": refresh_token},
)
assert resp.status_code == 200
assert "access_token" in resp.json()
@pytest.mark.asyncio
async def test_refresh_token_cannot_be_reused(client: AsyncClient):
"""Refresh token is revoked after use (rotation)."""
await client.post(
"/api/v1/auth/register",
json={"email": "eve@example.com", "password": "pass", "full_name": "Eve"},
)
login_resp = await client.post(
"/api/v1/auth/login",
data={"username": "eve@example.com", "password": "pass"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
refresh_token = login_resp.json()["refresh_token"]
# First use
r1 = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
assert r1.status_code == 200
# Second use of the same token should fail
r2 = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
assert r2.status_code == 401
@pytest.mark.asyncio
async def test_logout(client: AsyncClient):
_, token = await create_user_and_login(client, email="frank@example.com")
login_resp = await client.post(
"/api/v1/auth/login",
data={"username": "frank@example.com", "password": "password123"},
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
refresh_token = login_resp.json()["refresh_token"]
resp = await client.post(
"/api/v1/auth/logout",
json={"refresh_token": refresh_token},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 204
# Token should be revoked now
r = await client.post("/api/v1/auth/refresh", json={"refresh_token": refresh_token})
assert r.status_code == 401
+161
View File
@@ -0,0 +1,161 @@
"""Tests for category CRUD endpoints."""
import pytest
from httpx import AsyncClient
from tests.conftest import create_user_and_login
async def _get_first_category(client: AsyncClient, token: str) -> dict:
resp = await client.get(
"/api/v1/categories",
headers={"Authorization": f"Bearer {token}"},
)
return resp.json()[0]
@pytest.mark.asyncio
async def test_list_categories_after_register(client: AsyncClient):
_, token = await create_user_and_login(client)
resp = await client.get(
"/api/v1/categories",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
cats = resp.json()
assert len(cats) == 9 # 6 expense + 3 income defaults
for c in cats:
assert c["is_default"] is True
@pytest.mark.asyncio
async def test_create_category(client: AsyncClient):
_, token = await create_user_and_login(client)
resp = await client.post(
"/api/v1/categories",
json={"name": "Épargne", "type": "expense", "color": "#123456", "icon": "piggy-bank"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Épargne"
assert data["color"] == "#123456"
assert data["is_default"] is False
@pytest.mark.asyncio
async def test_update_category(client: AsyncClient):
_, token = await create_user_and_login(client)
create_resp = await client.post(
"/api/v1/categories",
json={"name": "Old Name", "type": "income"},
headers={"Authorization": f"Bearer {token}"},
)
cat_id = create_resp.json()["id"]
resp = await client.put(
f"/api/v1/categories/{cat_id}",
json={"name": "New Name", "color": "#aabbcc"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["name"] == "New Name"
assert resp.json()["color"] == "#aabbcc"
@pytest.mark.asyncio
async def test_delete_category_no_transactions(client: AsyncClient):
_, token = await create_user_and_login(client)
create_resp = await client.post(
"/api/v1/categories",
json={"name": "To Delete", "type": "expense"},
headers={"Authorization": f"Bearer {token}"},
)
cat_id = create_resp.json()["id"]
resp = await client.delete(
f"/api/v1/categories/{cat_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 204
# Category should no longer appear in listing
list_resp = await client.get(
"/api/v1/categories",
headers={"Authorization": f"Bearer {token}"},
)
ids = [c["id"] for c in list_resp.json()]
assert cat_id not in ids
@pytest.mark.asyncio
async def test_delete_category_with_transactions_returns_409(client: AsyncClient):
_, token = await create_user_and_login(client)
# Get an existing default category
cat = await _get_first_category(client, token)
cat_id = cat["id"]
# Add a transaction linked to it
await client.post(
"/api/v1/transactions",
json={
"amount_cents": 1000,
"type": "expense",
"category_id": cat_id,
"transaction_date": "2026-03-15",
},
headers={"Authorization": f"Bearer {token}"},
)
resp = await client.delete(
f"/api/v1/categories/{cat_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_filter_categories_by_type(client: AsyncClient):
_, token = await create_user_and_login(client)
resp = await client.get(
"/api/v1/categories?type=income",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
for c in resp.json():
assert c["type"] == "income"
@pytest.mark.asyncio
async def test_category_isolation_between_users(client: AsyncClient):
"""User A cannot see or modify User B's categories."""
_, token_a = await create_user_and_login(client, email="a@example.com")
_, token_b = await create_user_and_login(client, email="b@example.com")
# User A creates a category
resp = await client.post(
"/api/v1/categories",
json={"name": "A's private", "type": "expense"},
headers={"Authorization": f"Bearer {token_a}"},
)
cat_id = resp.json()["id"]
# User B cannot delete it
del_resp = await client.delete(
f"/api/v1/categories/{cat_id}",
headers={"Authorization": f"Bearer {token_b}"},
)
assert del_resp.status_code == 404
@pytest.mark.asyncio
async def test_get_nonexistent_category_returns_404(client: AsyncClient):
_, token = await create_user_and_login(client)
fake_id = "00000000-0000-0000-0000-000000000000"
resp = await client.delete(
f"/api/v1/categories/{fake_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
+265
View File
@@ -0,0 +1,265 @@
"""Tests for transaction CRUD endpoints."""
import pytest
from httpx import AsyncClient
from tests.conftest import create_user_and_login
async def _get_category_id(client: AsyncClient, token: str, type_: str = "expense") -> str:
resp = await client.get(
f"/api/v1/categories?type={type_}",
headers={"Authorization": f"Bearer {token}"},
)
return resp.json()[0]["id"]
async def _create_tx(
client: AsyncClient,
token: str,
category_id: str,
*,
amount_cents: int = 5000,
type_: str = "expense",
date: str = "2026-03-15",
description: str | None = None,
) -> dict:
payload = {
"amount_cents": amount_cents,
"type": type_,
"category_id": category_id,
"transaction_date": date,
}
if description:
payload["description"] = description
resp = await client.post(
"/api/v1/transactions",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()
@pytest.mark.asyncio
async def test_create_transaction(client: AsyncClient):
_, token = await create_user_and_login(client)
cat_id = await _get_category_id(client, token)
resp = await client.post(
"/api/v1/transactions",
json={
"amount_cents": 4500,
"type": "expense",
"category_id": cat_id,
"description": "Courses",
"transaction_date": "2026-03-15",
},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201
data = resp.json()
assert data["amount_cents"] == 4500
assert data["type"] == "expense"
assert data["description"] == "Courses"
assert data["category"]["id"] == cat_id
@pytest.mark.asyncio
async def test_create_transaction_invalid_amount(client: AsyncClient):
_, token = await create_user_and_login(client)
cat_id = await _get_category_id(client, token)
resp = await client.post(
"/api/v1/transactions",
json={"amount_cents": 0, "type": "expense", "category_id": cat_id, "transaction_date": "2026-03-15"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_list_transactions(client: AsyncClient):
_, token = await create_user_and_login(client)
cat_id = await _get_category_id(client, token)
await _create_tx(client, token, cat_id, amount_cents=1000)
await _create_tx(client, token, cat_id, amount_cents=2000)
resp = await client.get(
"/api/v1/transactions",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
assert len(data["items"]) == 2
@pytest.mark.asyncio
async def test_list_transactions_filter_by_month(client: AsyncClient):
_, token = await create_user_and_login(client)
cat_id = await _get_category_id(client, token)
await _create_tx(client, token, cat_id, date="2026-02-10")
await _create_tx(client, token, cat_id, date="2026-03-15")
await _create_tx(client, token, cat_id, date="2026-03-20")
resp = await client.get(
"/api/v1/transactions?month=2026-03",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 2
for item in data["items"]:
assert item["transaction_date"].startswith("2026-03")
@pytest.mark.asyncio
async def test_list_transactions_filter_by_category(client: AsyncClient):
_, token = await create_user_and_login(client)
cats = (await client.get("/api/v1/categories?type=expense", headers={"Authorization": f"Bearer {token}"})).json()
cat_a = cats[0]["id"]
cat_b = cats[1]["id"]
await _create_tx(client, token, cat_a)
await _create_tx(client, token, cat_b)
resp = await client.get(
f"/api/v1/transactions?category_id={cat_a}",
headers={"Authorization": f"Bearer {token}"},
)
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["category"]["id"] == cat_a
@pytest.mark.asyncio
async def test_list_transactions_filter_by_type(client: AsyncClient):
_, token = await create_user_and_login(client)
exp_cat = await _get_category_id(client, token, "expense")
inc_cat = await _get_category_id(client, token, "income")
await _create_tx(client, token, exp_cat, type_="expense")
await _create_tx(client, token, inc_cat, type_="income")
resp = await client.get(
"/api/v1/transactions?type=income",
headers={"Authorization": f"Bearer {token}"},
)
data = resp.json()
assert data["total"] == 1
assert data["items"][0]["type"] == "income"
@pytest.mark.asyncio
async def test_get_transaction(client: AsyncClient):
_, token = await create_user_and_login(client)
cat_id = await _get_category_id(client, token)
tx = await _create_tx(client, token, cat_id)
resp = await client.get(
f"/api/v1/transactions/{tx['id']}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["id"] == tx["id"]
@pytest.mark.asyncio
async def test_update_transaction(client: AsyncClient):
_, token = await create_user_and_login(client)
cat_id = await _get_category_id(client, token)
tx = await _create_tx(client, token, cat_id, amount_cents=1000)
resp = await client.put(
f"/api/v1/transactions/{tx['id']}",
json={"amount_cents": 9999, "description": "Updated"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
assert resp.json()["amount_cents"] == 9999
assert resp.json()["description"] == "Updated"
@pytest.mark.asyncio
async def test_soft_delete_transaction(client: AsyncClient):
_, token = await create_user_and_login(client)
cat_id = await _get_category_id(client, token)
tx = await _create_tx(client, token, cat_id)
del_resp = await client.delete(
f"/api/v1/transactions/{tx['id']}",
headers={"Authorization": f"Bearer {token}"},
)
assert del_resp.status_code == 204
# GET should 404
get_resp = await client.get(
f"/api/v1/transactions/{tx['id']}",
headers={"Authorization": f"Bearer {token}"},
)
assert get_resp.status_code == 404
# Should not appear in list
list_resp = await client.get(
"/api/v1/transactions",
headers={"Authorization": f"Bearer {token}"},
)
ids = [t["id"] for t in list_resp.json()["items"]]
assert tx["id"] not in ids
@pytest.mark.asyncio
async def test_transaction_isolation_between_users(client: AsyncClient):
"""User A cannot see or modify User B's transactions."""
_, token_a = await create_user_and_login(client, email="a@example.com")
_, token_b = await create_user_and_login(client, email="b@example.com")
cat_id_a = await _get_category_id(client, token_a)
tx = await _create_tx(client, token_a, cat_id_a)
# User B gets 404 on User A's transaction
resp = await client.get(
f"/api/v1/transactions/{tx['id']}",
headers={"Authorization": f"Bearer {token_b}"},
)
assert resp.status_code == 404
# User B's list is empty
list_resp = await client.get(
"/api/v1/transactions",
headers={"Authorization": f"Bearer {token_b}"},
)
assert list_resp.json()["total"] == 0
@pytest.mark.asyncio
async def test_pagination(client: AsyncClient):
_, token = await create_user_and_login(client)
cat_id = await _get_category_id(client, token)
for i in range(5):
await _create_tx(client, token, cat_id, amount_cents=100 * (i + 1))
resp = await client.get(
"/api/v1/transactions?page=1&per_page=2",
headers={"Authorization": f"Bearer {token}"},
)
data = resp.json()
assert data["total"] == 5
assert len(data["items"]) == 2
assert data["page"] == 1
assert data["per_page"] == 2
@pytest.mark.asyncio
async def test_get_nonexistent_transaction_returns_404(client: AsyncClient):
_, token = await create_user_and_login(client)
fake_id = "00000000-0000-0000-0000-000000000000"
resp = await client.get(
f"/api/v1/transactions/{fake_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 404
+25
View File
@@ -0,0 +1,25 @@
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- ./backend:/app
environment:
DEBUG: "true"
frontend:
image: node:20-alpine
working_dir: /app
command: sh -c "npm install && npm run dev -- --host 0.0.0.0"
volumes:
- ./frontend:/app
- frontend_node_modules:/app/node_modules
ports:
- "5173:5173"
environment:
NODE_ENV: development
volumes:
frontend_node_modules:
+46
View File
@@ -0,0 +1,46 @@
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-budget}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-budget}
POSTGRES_DB: ${POSTGRES_DB:-budget_tracker}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget}"]
interval: 5s
timeout: 5s
retries: 5
backend:
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
environment:
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-budget}:${POSTGRES_PASSWORD:-budget}@db:5432/${POSTGRES_DB:-budget_tracker}
SECRET_KEY: ${SECRET_KEY:-change-me-in-production}
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000"]'
DEBUG: ${DEBUG:-false}
ports:
- "${BACKEND_PORT:-8000}:8000"
depends_on:
db:
condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-3000}:80"
depends_on:
- backend
volumes:
pgdata:
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"]
}
+15
View File
@@ -0,0 +1,15 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Budget Tracker</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+15
View File
@@ -0,0 +1,15 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
+54
View File
@@ -0,0 +1,54 @@
{
"name": "budget-tracker-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@radix-ui/react-dialog": "1.1.4",
"@radix-ui/react-dropdown-menu": "2.1.4",
"@radix-ui/react-label": "2.1.1",
"@radix-ui/react-popover": "1.1.4",
"@radix-ui/react-select": "2.1.4",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-toast": "1.2.4",
"@tanstack/react-query": "5.62.8",
"axios": "1.7.9",
"class-variance-authority": "0.7.1",
"clsx": "2.1.1",
"lucide-react": "0.468.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-router-dom": "7.1.1",
"recharts": "2.15.0",
"tailwind-merge": "2.6.0",
"tailwindcss-animate": "1.0.7"
},
"devDependencies": {
"@eslint/js": "9.17.0",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@vitejs/plugin-react": "4.3.4",
"autoprefixer": "10.4.20",
"eslint": "9.17.0",
"eslint-plugin-react-hooks": "5.1.0",
"eslint-plugin-react-refresh": "0.4.16",
"globals": "15.14.0",
"postcss": "8.4.49",
"prettier": "3.4.2",
"prettier-plugin-tailwindcss": "0.6.9",
"tailwindcss": "3.4.17",
"typescript": "5.7.2",
"typescript-eslint": "8.18.2",
"vite": "6.0.6",
"vitest": "2.1.8"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+10
View File
@@ -0,0 +1,10 @@
import { Routes, Route } from "react-router-dom";
import HomePage from "./pages/HomePage";
export default function App() {
return (
<Routes>
<Route path="/" element={<HomePage />} />
</Routes>
);
}
+8
View File
@@ -0,0 +1,8 @@
import axios from "axios";
export const apiClient = axios.create({
baseURL: "/api",
headers: {
"Content-Type": "application/json",
},
});
View File
View File
+37
View File
@@ -0,0 +1,37 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
font-feature-settings: "rlig" 1, "calt" 1;
}
}
+25
View File
@@ -0,0 +1,25 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
import "./index.css";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
});
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
);
+25
View File
@@ -0,0 +1,25 @@
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "../api/client";
export default function HomePage() {
const { data: health } = useQuery({
queryKey: ["health"],
queryFn: () => apiClient.get("/health").then((r) => r.data),
});
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background text-foreground">
<div className="text-center">
<h1 className="text-4xl font-bold tracking-tight">Budget Tracker</h1>
<p className="mt-4 text-lg text-muted-foreground">
Gérez votre budget personnel en toute simplicité.
</p>
{health && (
<p className="mt-2 text-sm text-muted-foreground">
API: {health.status}
</p>
)}
</div>
</div>
);
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+57
View File
@@ -0,0 +1,57 @@
/** @type {import('tailwindcss').Config} */
export default {
darkMode: ["class"],
content: ["./index.html", "./src/**/*.{ts,tsx}"],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
},
},
plugins: [require("tailwindcss-animate")],
};
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["src"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
rewrite: (p) => p.replace(/^\/api/, ""),
},
},
},
});