diff --git a/backend/alembic/env.py b/backend/alembic/env.py index 69499b4..b9d62cb 100644 --- a/backend/alembic/env.py +++ b/backend/alembic/env.py @@ -6,6 +6,7 @@ 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 diff --git a/backend/alembic/versions/001_initial_models.py b/backend/alembic/versions/001_initial_models.py new file mode 100644 index 0000000..f254934 --- /dev/null +++ b/backend/alembic/versions/001_initial_models.py @@ -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") diff --git a/backend/app/auth/__init__.py b/backend/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/auth/dependencies.py b/backend/app/auth/dependencies.py new file mode 100644 index 0000000..31a8cc6 --- /dev/null +++ b/backend/app/auth/dependencies.py @@ -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 diff --git a/backend/app/auth/router.py b/backend/app/auth/router.py new file mode 100644 index 0000000..7a4b247 --- /dev/null +++ b/backend/app/auth/router.py @@ -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() diff --git a/backend/app/auth/security.py b/backend/app/auth/security.py new file mode 100644 index 0000000..a9b122f --- /dev/null +++ b/backend/app/auth/security.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 1a65281..1ece1f0 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,14 @@ -from contextlib import asynccontextmanager 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 @@ -33,6 +36,11 @@ def create_app() -> FastAPI: 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 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index e69de29..dc5e5dd 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/budget.py b/backend/app/models/budget.py new file mode 100644 index 0000000..bffcf1f --- /dev/null +++ b/backend/app/models/budget.py @@ -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") diff --git a/backend/app/models/category.py b/backend/app/models/category.py new file mode 100644 index 0000000..9063d7a --- /dev/null +++ b/backend/app/models/category.py @@ -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() + ) diff --git a/backend/app/models/refresh_token.py b/backend/app/models/refresh_token.py new file mode 100644 index 0000000..895c340 --- /dev/null +++ b/backend/app/models/refresh_token.py @@ -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() + ) diff --git a/backend/app/models/transaction.py b/backend/app/models/transaction.py new file mode 100644 index 0000000..7b282b0 --- /dev/null +++ b/backend/app/models/transaction.py @@ -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") diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..ff030cd --- /dev/null +++ b/backend/app/models/user.py @@ -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() + ) diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py new file mode 100644 index 0000000..29afab5 --- /dev/null +++ b/backend/app/routers/categories.py @@ -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) diff --git a/backend/app/routers/transactions.py b/backend/app/routers/transactions.py new file mode 100644 index 0000000..b72c28b --- /dev/null +++ b/backend/app/routers/transactions.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..bb0b4c1 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/schemas/category.py b/backend/app/schemas/category.py new file mode 100644 index 0000000..b05441b --- /dev/null +++ b/backend/app/schemas/category.py @@ -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} diff --git a/backend/app/schemas/common.py b/backend/app/schemas/common.py new file mode 100644 index 0000000..393fe79 --- /dev/null +++ b/backend/app/schemas/common.py @@ -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 diff --git a/backend/app/schemas/transaction.py b/backend/app/schemas/transaction.py new file mode 100644 index 0000000..a071948 --- /dev/null +++ b/backend/app/schemas/transaction.py @@ -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] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/__init__.py,cover b/backend/app/services/__init__.py,cover new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/category_service.py b/backend/app/services/category_service.py new file mode 100644 index 0000000..a9704bf --- /dev/null +++ b/backend/app/services/category_service.py @@ -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() diff --git a/backend/app/services/category_service.py,cover b/backend/app/services/category_service.py,cover new file mode 100644 index 0000000..a5795a4 --- /dev/null +++ b/backend/app/services/category_service.py,cover @@ -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() diff --git a/backend/app/services/transaction_service.py b/backend/app/services/transaction_service.py new file mode 100644 index 0000000..62481ef --- /dev/null +++ b/backend/app/services/transaction_service.py @@ -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() diff --git a/backend/app/services/transaction_service.py,cover b/backend/app/services/transaction_service.py,cover new file mode 100644 index 0000000..1778d9a --- /dev/null +++ b/backend/app/services/transaction_service.py,cover @@ -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() diff --git a/backend/app/utils.py b/backend/app/utils.py new file mode 100644 index 0000000..6f163d3 --- /dev/null +++ b/backend/app/utils.py @@ -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) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3c112f6..0a1b5ff 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -25,4 +25,17 @@ 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", +] diff --git a/backend/requirements.txt b/backend/requirements.txt index c11f611..73efd16 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,10 +5,15 @@ 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 diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..9ec56a5 --- /dev/null +++ b/backend/tests/conftest.py @@ -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"] diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000..0d0b9df --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 diff --git a/backend/tests/test_categories.py b/backend/tests/test_categories.py new file mode 100644 index 0000000..b6858c0 --- /dev/null +++ b/backend/tests/test_categories.py @@ -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 diff --git a/backend/tests/test_transactions.py b/backend/tests/test_transactions.py new file mode 100644 index 0000000..ae52c91 --- /dev/null +++ b/backend/tests/test_transactions.py @@ -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