Compare commits

...

5 Commits

Author SHA1 Message Date
Nox (OpenClaw) 434de9aa3e feat: production docker + documentation
- Backend Dockerfile: multi-stage build with venv, gunicorn+uvicorn workers, entrypoint runs alembic then gunicorn
- Frontend Dockerfile: multi-stage with npm ci, nginx:1.27-alpine runtime
- nginx.conf: gzip compression, security headers (X-Frame-Options, X-Content-Type-Options, etc.), static asset caching, correct API proxy preserving /api/ prefix
- docker-compose.yml: production config — db healthcheck, backend healthcheck, frontend depends_on backend healthy, no exposed backend port
- docker-compose.override.yml: dev hot-reload — uvicorn --reload with source mount, npm run dev on port 5173
- Rate limiting: slowapi middleware on /auth/login (10/min) and /auth/register (5/min)
- README.md: full documentation with architecture, quick start, API table, dev/prod instructions, tests
- .env.example: all variables documented with comments
- .gitignore: extended with *.pyc, *.cover, .ruff_cache, frontend/.vite, etc.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 17:07:38 +00:00
Nox (OpenClaw) e3fac99045 feat: advanced features — dashboard, budgets, history, export
Backend:
- GET /api/v1/dashboard?month=YYYY-MM: KPIs, by_category, 6-month trend, budget alerts
- GET/POST/PUT/DELETE /api/v1/budgets: budget envelopes with spent_cents/remaining_cents
- POST /api/v1/budgets/rollover: copy budgets from M-1 to target month
- GET /api/v1/history?year=YYYY: monthly summary for the year
- GET /api/v1/export/csv|pdf?month=YYYY-MM: StreamingResponse exports (WeasyPrint PDF)
- New schemas: dashboard, budget, history
- Services: dashboard_service, budget_service
- Routers mounted in main.py

Frontend:
- DashboardPage: 4 KPI cards, PieChart (expenses by category), BarChart (6-month trend),
  month navigation, budget alert badges, CSV/PDF export buttons
- BudgetsPage: progress bars (green/orange/red), create/edit form, delete, rollover M-1
- HistoryPage: annual table with month click → dashboard, LineChart revenues/expenses
- CategoriesPage: list by type with create/edit/delete (was missing from Phase 2)
- TransactionsPage: added CSV/PDF export buttons
- App.tsx: full routing with ProtectedRoute + Layout for all authenticated pages
- New hooks: useDashboard, useBudgets (with mutations), useHistory
- API types + client updated for all new endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-17 16:48:26 +00:00
Nox (OpenClaw) 9f7378cb69 feat: frontend core — auth, layout, transactions, categories 2026-03-17 16:36:47 +00:00
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
95 changed files with 7217 additions and 0 deletions
+26
View File
@@ -0,0 +1,26 @@
# ── PostgreSQL ──────────────────────────────────────────────────────────────
POSTGRES_USER=budget
POSTGRES_PASSWORD=budget
POSTGRES_DB=budget_tracker
# ── Backend (FastAPI) ───────────────────────────────────────────────────────
# Full connection URL — built from the vars above by docker-compose
DATABASE_URL=postgresql+asyncpg://budget:budget@db:5432/budget_tracker
# IMPORTANT: generate a strong random key for production
# python -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change-me-in-production
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
# Comma-separated list of allowed origins (JSON array syntax)
# Production: set to your actual frontend domain, e.g. ["https://budget.example.com"]
CORS_ORIGINS=["http://localhost"]
# ── Ports (override for local conflicts) ────────────────────────────────────
# Production: frontend is exposed on port 80 (FRONTEND_PORT)
FRONTEND_PORT=80
# Dev only:
DB_PORT=5432
BACKEND_PORT=8000
+45
View File
@@ -0,0 +1,45 @@
# Python
__pycache__/
*.py[cod]
*.pyc
*.pyo
*.egg-info/
.venv/
venv/
*.cover
.coverage
htmlcov/
.pytest_cache/
.ruff_cache/
*.egg
# Node
node_modules/
frontend/dist/
frontend/.vite/
*.tsbuildinfo
# Environment — never commit secrets
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*.sublime-project
*.sublime-workspace
# OS
.DS_Store
Thumbs.db
desktop.ini
# Docker
pgdata/
# Build artifacts
dist/
build/
+155
View File
@@ -0,0 +1,155 @@
# Budget Tracker
A personal finance web application to track income, expenses, and budgets with analytics and CSV/PDF export.
<!-- Screenshots placeholder -->
<!-- ![Dashboard](docs/screenshots/dashboard.png) -->
---
## Stack
| Layer | Technology |
|-----------|-----------------------------------------------------|
| Backend | FastAPI 0.115, SQLAlchemy 2.0 async, Alembic |
| Database | PostgreSQL 16 |
| Frontend | React 18, TypeScript, Vite, Tailwind CSS, Radix UI |
| Charts | Recharts |
| Export | CSV (native), PDF (WeasyPrint) |
| Server | Gunicorn + Uvicorn workers, Nginx 1.27 |
---
## Quick Start
```bash
# 1. Copy and configure environment
cp .env.example .env
# 2. Start all services (production mode)
docker compose up -d
# App is available at http://localhost
# API docs at http://localhost/api/v1/docs
```
> The backend automatically runs `alembic upgrade head` on startup.
---
## Architecture
```
┌─────────────┐ ┌──────────────────┐ ┌──────────────┐
│ Browser │──80──▶│ Nginx (frontend) │──/api/▶│ FastAPI │
└─────────────┘ │ serves SPA dist │ │ (gunicorn) │
└──────────────────┘ └──────┬───────┘
┌──────▼───────┐
│ PostgreSQL │
└──────────────┘
```
- **Frontend**: React SPA served by Nginx. All `/api/*` requests are proxied to the backend.
- **Backend**: FastAPI with async SQLAlchemy. Exposes REST API under `/api/v1`.
- **Database**: PostgreSQL with Alembic migrations applied at container start.
---
## API Endpoints
Interactive docs (Swagger UI): `http://localhost/api/v1/docs`
| Module | Prefix | Description |
|--------------|-------------------------|------------------------------------|
| Auth | `/api/v1/auth` | Register, login, refresh, logout |
| Transactions | `/api/v1/transactions` | CRUD + pagination + filters |
| Categories | `/api/v1/categories` | Income / expense categories |
| Budgets | `/api/v1/budgets` | Monthly budgets + rollover |
| Dashboard | `/api/v1/dashboard` | Monthly summary + charts data |
| History | `/api/v1/history` | Year-over-year analytics |
| Export | `/api/v1/export` | CSV and PDF export |
| Health | `/health` | Liveness probe |
---
## Development (hot-reload)
The `docker-compose.override.yml` is automatically merged in dev, enabling:
- Backend: `uvicorn --reload` with source code mounted
- Frontend: `npm run dev` (Vite HMR) on port 5173
```bash
# Start dev environment
cp .env.example .env
docker compose up
# Backend API: http://localhost:8000
# Frontend dev server: http://localhost:5173
# API docs: http://localhost:8000/api/v1/docs
```
---
## Production
```bash
# Use only the base compose file (no override)
docker compose -f docker-compose.yml up -d
# Generate a secure SECRET_KEY
python -c "import secrets; print(secrets.token_hex(32))"
# Set in .env:
# SECRET_KEY=<generated value>
# CORS_ORIGINS=["https://your-domain.com"]
```
---
## Tests
```bash
# Backend tests
cd backend
pip install -r requirements.txt
pytest
# With coverage
pytest --cov=app --cov-report=term-missing
# Frontend type check
cd frontend
npm run build # tsc -b + vite build
```
---
## Project Structure
```
budget-tracker/
├── backend/
│ ├── app/
│ │ ├── auth/ # JWT auth (register, login, refresh, logout)
│ │ ├── models/ # SQLAlchemy ORM models
│ │ ├── routers/ # FastAPI routers (transactions, budgets, …)
│ │ ├── schemas/ # Pydantic request/response schemas
│ │ ├── services/ # Business logic layer
│ │ ├── config.py # Settings (pydantic-settings)
│ │ ├── database.py # Async engine + session factory
│ │ └── main.py # App factory + middleware
│ ├── alembic/ # Database migrations
│ ├── tests/ # pytest test suite
│ ├── Dockerfile # Multi-stage build (venv → slim runtime)
│ └── entrypoint.sh # alembic upgrade head → gunicorn
└── frontend/
├── src/
│ ├── api/ # Axios client + API functions
│ ├── components/ # Shared UI components
│ ├── hooks/ # React Query hooks
│ ├── pages/ # Route-level page components
│ └── stores/ # Zustand auth store
├── Dockerfile # Multi-stage build (npm ci → nginx)
└── nginx.conf # Gzip, security headers, SPA fallback, API proxy
```
+55
View File
@@ -0,0 +1,55 @@
# ── Stage 1: build ──────────────────────────────────────────────────────────
FROM python:3.12-slim AS build
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Create virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# System deps required by WeasyPrint (build time)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gcc \
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
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH"
# System deps required by WeasyPrint (runtime)
RUN apt-get update && \
apt-get install -y --no-install-recommends \
libpango-1.0-0 \
libpangocairo-1.0-0 \
libgdk-pixbuf2.0-0 \
libcairo2 \
libglib2.0-0 && \
rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
COPY . .
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
EXPOSE 8000
ENTRYPOINT ["/entrypoint.sh"]
+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
+156
View File
@@ -0,0 +1,156 @@
import uuid
from datetime import timedelta
from fastapi import APIRouter, Depends, HTTPException, Request, 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.limiter import limiter
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)
@limiter.limit("5/minute")
async def register(
request: Request,
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)
@limiter.limit("10/minute")
async def login(
request: Request,
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.get("/me", response_model=UserResponse)
async def get_me(current_user: User = Depends(get_current_user)) -> UserResponse:
return UserResponse.model_validate(current_user)
@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
+4
View File
@@ -0,0 +1,4 @@
from slowapi import Limiter
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
+63
View File
@@ -0,0 +1,63 @@
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from app.auth.router import router as auth_router
from app.config import settings
from app.database import engine
from app.limiter import limiter
from app.routers.budgets import router as budgets_router
from app.routers.categories import router as categories_router
from app.routers.dashboard import router as dashboard_router
from app.routers.export import router as export_router
from app.routers.history import router as history_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.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.add_middleware(SlowAPIMiddleware)
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)
app.include_router(dashboard_router, prefix=api_prefix)
app.include_router(budgets_router, prefix=api_prefix)
app.include_router(history_router, prefix=api_prefix)
app.include_router(export_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
+67
View File
@@ -0,0 +1,67 @@
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.user import User
from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
from app.services import budget_service
from app.utils import utcnow
router = APIRouter(prefix="/budgets", tags=["budgets"])
def _current_month() -> str:
now = utcnow()
return f"{now.year}-{now.month:02d}"
@router.get("", response_model=list[BudgetResponse])
async def list_budgets(
month: str = Query(default=None, description="Month YYYY-MM (defaults to current month)"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> list[BudgetResponse]:
if month is None:
month = _current_month()
return await budget_service.list_budgets(session, current_user.id, month)
@router.post("", response_model=BudgetResponse, status_code=status.HTTP_201_CREATED)
async def create_budget(
data: BudgetCreate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> BudgetResponse:
return await budget_service.create_budget(session, current_user.id, data)
@router.put("/{budget_id}", response_model=BudgetResponse)
async def update_budget(
budget_id: uuid.UUID,
data: BudgetUpdate,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> BudgetResponse:
return await budget_service.update_budget(session, current_user.id, budget_id, data)
@router.delete("/{budget_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_budget(
budget_id: uuid.UUID,
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> None:
await budget_service.delete_budget(session, current_user.id, budget_id)
@router.post("/rollover", response_model=list[BudgetResponse], status_code=status.HTTP_201_CREATED)
async def rollover_budgets(
month: str = Query(..., description="Target month YYYY-MM to copy budgets into"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> list[BudgetResponse]:
"""Copy budgets from month-1 into the given month (skips existing ones)."""
return await budget_service.rollover_budgets(session, current_user.id, month)
+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)
+27
View File
@@ -0,0 +1,27 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.auth.dependencies import get_current_user
from app.database import get_session
from app.models.user import User
from app.schemas.dashboard import DashboardResponse
from app.services import dashboard_service
from app.utils import utcnow
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
def _current_month() -> str:
now = utcnow()
return f"{now.year}-{now.month:02d}"
@router.get("", response_model=DashboardResponse)
async def get_dashboard(
month: str = Query(default=None, description="Month YYYY-MM (defaults to current month)"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> DashboardResponse:
if month is None:
month = _current_month()
return await dashboard_service.get_dashboard(session, current_user.id, month)
+175
View File
@@ -0,0 +1,175 @@
import csv
import io
from datetime import date
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.auth.dependencies import get_current_user
from app.database import get_session
from app.models.transaction import Transaction
from app.models.user import User
from app.utils import utcnow
router = APIRouter(prefix="/export", tags=["export"])
def _month_bounds(month: str) -> tuple[date, date]:
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 _fetch_transactions(
session: AsyncSession,
user_id,
month: str,
) -> list[Transaction]:
start, end = _month_bounds(month)
result = await session.execute(
select(Transaction)
.options(selectinload(Transaction.category))
.where(
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
Transaction.transaction_date >= start,
Transaction.transaction_date < end,
)
.order_by(Transaction.transaction_date, Transaction.created_at)
)
return list(result.scalars().all())
@router.get("/csv")
async def export_csv(
month: str = Query(default=None, description="Month YYYY-MM"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> StreamingResponse:
if month is None:
now = utcnow()
month = f"{now.year}-{now.month:02d}"
transactions = await _fetch_transactions(session, current_user.id, month)
output = io.StringIO()
writer = csv.writer(output, delimiter=";")
writer.writerow(["Date", "Type", "Catégorie", "Description", "Montant (€)"])
for tx in transactions:
amount_eur = tx.amount_cents / 100
if tx.type.value == "expense":
amount_eur = -amount_eur
writer.writerow([
tx.transaction_date.isoformat(),
tx.type.value,
tx.category.name,
tx.description or "",
f"{amount_eur:.2f}",
])
output.seek(0)
filename = f"transactions_{month}.csv"
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/pdf")
async def export_pdf(
month: str = Query(default=None, description="Month YYYY-MM"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> StreamingResponse:
if month is None:
now = utcnow()
month = f"{now.year}-{now.month:02d}"
transactions = await _fetch_transactions(session, current_user.id, month)
total_income = sum(t.amount_cents for t in transactions if t.type.value == "income")
total_expense = sum(t.amount_cents for t in transactions if t.type.value == "expense")
rows_html = ""
for tx in transactions:
amount_eur = tx.amount_cents / 100
color = "#16a34a" if tx.type.value == "income" else "#dc2626"
sign = "+" if tx.type.value == "income" else "-"
rows_html += f"""
<tr>
<td>{tx.transaction_date.isoformat()}</td>
<td>{tx.category.name}</td>
<td>{tx.description or ""}</td>
<td style="color:{color};text-align:right;font-weight:600">
{sign}{amount_eur:.2f}
</td>
</tr>"""
html = f"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8"/>
<style>
body {{ font-family: Arial, sans-serif; font-size: 12px; color: #1e293b; margin: 40px; }}
h1 {{ font-size: 20px; margin-bottom: 4px; }}
.subtitle {{ color: #64748b; margin-bottom: 24px; }}
.kpi {{ display: flex; gap: 40px; margin-bottom: 24px; }}
.kpi-card {{ background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px;
padding: 12px 20px; }}
.kpi-label {{ font-size: 11px; color: #64748b; }}
.kpi-value {{ font-size: 18px; font-weight: 700; margin-top: 4px; }}
table {{ width: 100%; border-collapse: collapse; }}
th {{ background: #1e293b; color: white; padding: 8px 12px; text-align: left; font-size: 11px; }}
td {{ padding: 7px 12px; border-bottom: 1px solid #e2e8f0; }}
tr:nth-child(even) td {{ background: #f8fafc; }}
.footer {{ margin-top: 24px; font-size: 10px; color: #94a3b8; text-align: right; }}
</style>
</head>
<body>
<h1>Rapport de transactions</h1>
<div class="subtitle">Période : {month} &nbsp;|&nbsp; {current_user.full_name or current_user.email}</div>
<div class="kpi">
<div class="kpi-card">
<div class="kpi-label">Revenus</div>
<div class="kpi-value" style="color:#16a34a">+{total_income/100:.2f} €</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Dépenses</div>
<div class="kpi-value" style="color:#dc2626">-{total_expense/100:.2f} €</div>
</div>
<div class="kpi-card">
<div class="kpi-label">Solde net</div>
<div class="kpi-value">{(total_income - total_expense)/100:+.2f} €</div>
</div>
</div>
<table>
<thead>
<tr>
<th>Date</th><th>Catégorie</th><th>Description</th><th style="text-align:right">Montant</th>
</tr>
</thead>
<tbody>
{rows_html}
</tbody>
</table>
<div class="footer">Généré le {utcnow().strftime('%d/%m/%Y')} — Budget Tracker</div>
</body>
</html>"""
import weasyprint # noqa: PLC0415
pdf_bytes = weasyprint.HTML(string=html).write_pdf()
filename = f"transactions_{month}.pdf"
return StreamingResponse(
iter([pdf_bytes]),
media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
+73
View File
@@ -0,0 +1,73 @@
from datetime import date
from fastapi import APIRouter, Depends, Query
from sqlalchemy import func, select
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 Transaction, TransactionType
from app.models.user import User
from app.schemas.history import HistoryResponse, MonthSummary
from app.utils import utcnow
router = APIRouter(prefix="/history", tags=["history"])
@router.get("", response_model=HistoryResponse)
async def get_history(
year: int = Query(default=None, description="Year (defaults to current year)"),
session: AsyncSession = Depends(get_session),
current_user: User = Depends(get_current_user),
) -> HistoryResponse:
if year is None:
year = utcnow().year
months: list[MonthSummary] = []
for mon in range(1, 13):
start = date(year, mon, 1)
if mon == 12:
end = date(year + 1, 1, 1)
else:
end = date(year, mon + 1, 1)
result = await session.execute(
select(
func.coalesce(
func.sum(
func.case(
(Transaction.type == TransactionType.income, Transaction.amount_cents),
else_=0,
)
),
0,
).label("income"),
func.coalesce(
func.sum(
func.case(
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
else_=0,
)
),
0,
).label("expense"),
func.count(Transaction.id).label("count"),
).where(
Transaction.user_id == current_user.id,
Transaction.deleted_at.is_(None),
Transaction.transaction_date >= start,
Transaction.transaction_date < end,
)
)
row = result.one()
months.append(
MonthSummary(
month=f"{year}-{mon:02d}",
income_cents=row.income,
expense_cents=row.expense,
balance_cents=row.income - row.expense,
transaction_count=row.count,
)
)
return HistoryResponse(year=year, months=months)
+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
+51
View File
@@ -0,0 +1,51 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, field_validator
class BudgetCreate(BaseModel):
category_id: uuid.UUID
month: str # YYYY-MM
limit_cents: int
@field_validator("limit_cents")
@classmethod
def limit_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("limit_cents must be positive")
return v
@field_validator("month")
@classmethod
def month_format(cls, v: str) -> str:
parts = v.split("-")
if len(parts) != 2 or not all(p.isdigit() for p in parts):
raise ValueError("month must be in YYYY-MM format")
return v
class BudgetUpdate(BaseModel):
limit_cents: int
@field_validator("limit_cents")
@classmethod
def limit_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError("limit_cents must be positive")
return v
class BudgetResponse(BaseModel):
id: uuid.UUID
category_id: uuid.UUID
category_name: str
category_color: str | None
month: str
limit_cents: int
spent_cents: int
remaining_cents: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
+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
+37
View File
@@ -0,0 +1,37 @@
import uuid
from pydantic import BaseModel
class CategoryExpense(BaseModel):
category_id: uuid.UUID
category_name: str
color: str | None
amount_cents: int
model_config = {"from_attributes": True}
class MonthlyTrend(BaseModel):
month: str # YYYY-MM
income_cents: int
expense_cents: int
class BudgetAlert(BaseModel):
budget_id: uuid.UUID
category_name: str
limit_cents: int
spent_cents: int
percentage: float
class DashboardResponse(BaseModel):
month: str
balance_cents: int # all-time cumulative balance
total_income_cents: int # month income
total_expense_cents: int # month expenses
net_cents: int # month net
by_category: list[CategoryExpense]
monthly_trend: list[MonthlyTrend]
budget_alerts: list[BudgetAlert]
+14
View File
@@ -0,0 +1,14 @@
from pydantic import BaseModel
class MonthSummary(BaseModel):
month: str # YYYY-MM
income_cents: int
expense_cents: int
balance_cents: int # month net
transaction_count: int
class HistoryResponse(BaseModel):
year: int
months: list[MonthSummary]
+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
+213
View File
@@ -0,0 +1,213 @@
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.budget import Budget
from app.models.transaction import Transaction, TransactionType
from app.schemas.budget import BudgetCreate, BudgetResponse, BudgetUpdate
from app.utils import utcnow
def _month_bounds(month: str) -> tuple[date, date]:
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
def _prev_month(month: str) -> str:
year, mon = map(int, month.split("-"))
if mon == 1:
return f"{year - 1}-12"
return f"{year}-{mon - 1:02d}"
async def _spent_cents(
session: AsyncSession,
user_id: uuid.UUID,
category_id: uuid.UUID,
month: str,
) -> int:
start, end = _month_bounds(month)
result = await session.execute(
select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
Transaction.type == TransactionType.expense,
Transaction.category_id == category_id,
Transaction.transaction_date >= start,
Transaction.transaction_date < end,
)
)
return result.scalar_one()
async def _to_response(
session: AsyncSession,
user_id: uuid.UUID,
budget: Budget,
) -> BudgetResponse:
spent = await _spent_cents(session, user_id, budget.category_id, budget.month)
return BudgetResponse(
id=budget.id,
category_id=budget.category_id,
category_name=budget.category.name,
category_color=budget.category.color,
month=budget.month,
limit_cents=budget.limit_cents,
spent_cents=spent,
remaining_cents=max(0, budget.limit_cents - spent),
created_at=budget.created_at,
updated_at=budget.updated_at,
)
async def list_budgets(
session: AsyncSession,
user_id: uuid.UUID,
month: str,
) -> list[BudgetResponse]:
result = await session.execute(
select(Budget)
.options(selectinload(Budget.category))
.where(Budget.user_id == user_id, Budget.month == month)
.order_by(Budget.created_at)
)
budgets = result.scalars().all()
return [await _to_response(session, user_id, b) for b in budgets]
async def create_budget(
session: AsyncSession,
user_id: uuid.UUID,
data: BudgetCreate,
) -> BudgetResponse:
# Check for duplicate
existing = await session.execute(
select(Budget).where(
Budget.user_id == user_id,
Budget.category_id == data.category_id,
Budget.month == data.month,
)
)
if existing.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Budget already exists for this category and month",
)
budget = Budget(
user_id=user_id,
category_id=data.category_id,
month=data.month,
limit_cents=data.limit_cents,
)
session.add(budget)
await session.commit()
result = await session.execute(
select(Budget)
.options(selectinload(Budget.category))
.where(Budget.id == budget.id)
)
budget = result.scalar_one()
return await _to_response(session, user_id, budget)
async def update_budget(
session: AsyncSession,
user_id: uuid.UUID,
budget_id: uuid.UUID,
data: BudgetUpdate,
) -> BudgetResponse:
result = await session.execute(
select(Budget)
.options(selectinload(Budget.category))
.where(Budget.id == budget_id, Budget.user_id == user_id)
)
budget = result.scalar_one_or_none()
if budget is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
budget.limit_cents = data.limit_cents
budget.updated_at = utcnow()
await session.commit()
result = await session.execute(
select(Budget)
.options(selectinload(Budget.category))
.where(Budget.id == budget_id)
)
budget = result.scalar_one()
return await _to_response(session, user_id, budget)
async def delete_budget(
session: AsyncSession,
user_id: uuid.UUID,
budget_id: uuid.UUID,
) -> None:
result = await session.execute(
select(Budget).where(Budget.id == budget_id, Budget.user_id == user_id)
)
budget = result.scalar_one_or_none()
if budget is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Budget not found")
await session.delete(budget)
await session.commit()
async def rollover_budgets(
session: AsyncSession,
user_id: uuid.UUID,
target_month: str,
) -> list[BudgetResponse]:
"""Copy budgets from previous month to target_month (skip existing)."""
source_month = _prev_month(target_month)
result = await session.execute(
select(Budget)
.options(selectinload(Budget.category))
.where(Budget.user_id == user_id, Budget.month == source_month)
)
source_budgets = result.scalars().all()
created = []
for src in source_budgets:
existing = await session.execute(
select(Budget).where(
Budget.user_id == user_id,
Budget.category_id == src.category_id,
Budget.month == target_month,
)
)
if existing.scalar_one_or_none() is not None:
continue
new_budget = Budget(
user_id=user_id,
category_id=src.category_id,
month=target_month,
limit_cents=src.limit_cents,
)
session.add(new_budget)
created.append(new_budget)
await session.commit()
responses = []
for b in created:
result = await session.execute(
select(Budget)
.options(selectinload(Budget.category))
.where(Budget.id == b.id)
)
b = result.scalar_one()
responses.append(await _to_response(session, user_id, b))
return responses
+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()
+222
View File
@@ -0,0 +1,222 @@
import uuid
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.models.budget import Budget
from app.models.category import Category
from app.models.transaction import Transaction, TransactionType
from app.schemas.dashboard import (
BudgetAlert,
CategoryExpense,
DashboardResponse,
MonthlyTrend,
)
def _month_bounds(month: str) -> tuple[date, date]:
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
def _prev_month(month: str) -> str:
year, mon = map(int, month.split("-"))
if mon == 1:
return f"{year - 1}-12"
return f"{year}-{mon - 1:02d}"
async def get_dashboard(
session: AsyncSession,
user_id: uuid.UUID,
month: str,
) -> DashboardResponse:
start, end = _month_bounds(month)
# --- All-time balance (cumulative since origin) ---
balance_result = await session.execute(
select(
func.coalesce(
func.sum(
func.case(
(Transaction.type == TransactionType.income, Transaction.amount_cents),
else_=-Transaction.amount_cents,
)
),
0,
)
).where(
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
)
)
balance_cents: int = balance_result.scalar_one()
# --- Month income & expenses ---
month_result = await session.execute(
select(
func.coalesce(
func.sum(
func.case(
(Transaction.type == TransactionType.income, Transaction.amount_cents),
else_=0,
)
),
0,
).label("income"),
func.coalesce(
func.sum(
func.case(
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
else_=0,
)
),
0,
).label("expense"),
).where(
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
Transaction.transaction_date >= start,
Transaction.transaction_date < end,
)
)
month_row = month_result.one()
total_income_cents: int = month_row.income
total_expense_cents: int = month_row.expense
# --- Expenses by category for the month ---
cat_result = await session.execute(
select(
Transaction.category_id,
func.sum(Transaction.amount_cents).label("total"),
)
.where(
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
Transaction.type == TransactionType.expense,
Transaction.transaction_date >= start,
Transaction.transaction_date < end,
)
.group_by(Transaction.category_id)
)
cat_rows = cat_result.all()
# Fetch category names
category_ids = [row.category_id for row in cat_rows]
by_category: list[CategoryExpense] = []
if category_ids:
cats_result = await session.execute(
select(Category).where(Category.id.in_(category_ids))
)
cats_map = {c.id: c for c in cats_result.scalars().all()}
for row in cat_rows:
cat = cats_map.get(row.category_id)
by_category.append(
CategoryExpense(
category_id=row.category_id,
category_name=cat.name if cat else "?",
color=cat.color if cat else None,
amount_cents=row.total,
)
)
# --- Monthly trend: last 6 months ---
monthly_trend: list[MonthlyTrend] = []
cur = month
months_to_fetch = []
for _ in range(6):
months_to_fetch.append(cur)
cur = _prev_month(cur)
months_to_fetch.reverse()
for m in months_to_fetch:
m_start, m_end = _month_bounds(m)
trend_result = await session.execute(
select(
func.coalesce(
func.sum(
func.case(
(Transaction.type == TransactionType.income, Transaction.amount_cents),
else_=0,
)
),
0,
).label("income"),
func.coalesce(
func.sum(
func.case(
(Transaction.type == TransactionType.expense, Transaction.amount_cents),
else_=0,
)
),
0,
).label("expense"),
).where(
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
Transaction.transaction_date >= m_start,
Transaction.transaction_date < m_end,
)
)
trend_row = trend_result.one()
monthly_trend.append(
MonthlyTrend(
month=m,
income_cents=trend_row.income,
expense_cents=trend_row.expense,
)
)
# --- Budget alerts (>= 80% spent) ---
budgets_result = await session.execute(
select(Budget)
.options(selectinload(Budget.category))
.where(
Budget.user_id == user_id,
Budget.month == month,
)
)
budgets = budgets_result.scalars().all()
budget_alerts: list[BudgetAlert] = []
for budget in budgets:
spent_result = await session.execute(
select(func.coalesce(func.sum(Transaction.amount_cents), 0)).where(
Transaction.user_id == user_id,
Transaction.deleted_at.is_(None),
Transaction.type == TransactionType.expense,
Transaction.category_id == budget.category_id,
Transaction.transaction_date >= start,
Transaction.transaction_date < end,
)
)
spent_cents: int = spent_result.scalar_one()
percentage = (spent_cents / budget.limit_cents * 100) if budget.limit_cents > 0 else 0
if percentage >= 80:
budget_alerts.append(
BudgetAlert(
budget_id=budget.id,
category_name=budget.category.name,
limit_cents=budget.limit_cents,
spent_cents=spent_cents,
percentage=round(percentage, 1),
)
)
return DashboardResponse(
month=month,
balance_cents=balance_cents,
total_income_cents=total_income_cents,
total_expense_cents=total_expense_cents,
net_cents=total_income_cents - total_expense_cents,
by_category=by_category,
monthly_trend=monthly_trend,
budget_alerts=budget_alerts,
)
+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)
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
echo "Running database migrations..."
alembic upgrade head
echo "Starting server..."
exec gunicorn app.main:app \
--workers 4 \
--worker-class uvicorn.workers.UvicornWorker \
--bind 0.0.0.0:8000 \
--timeout 120 \
--access-logfile - \
--error-logfile -
+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",
]
+21
View File
@@ -0,0 +1,21 @@
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
gunicorn==23.0.0
slowapi==0.1.9
# 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
+32
View File
@@ -0,0 +1,32 @@
services:
db:
ports:
- "${DB_PORT:-5432}:5432"
backend:
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
volumes:
- ./backend:/app
ports:
- "${BACKEND_PORT:-8000}:8000"
environment:
SECRET_KEY: ${SECRET_KEY:-dev-secret-not-for-production}
DEBUG: "true"
CORS_ORIGINS: '["http://localhost:5173","http://localhost:3000","http://localhost"]'
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
depends_on:
- backend
volumes:
frontend_node_modules:
+52
View File
@@ -0,0 +1,52 @@
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}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-budget} -d ${POSTGRES_DB:-budget_tracker}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
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}
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-15}
REFRESH_TOKEN_EXPIRE_DAYS: ${REFRESH_TOKEN_EXPIRE_DAYS:-7}
CORS_ORIGINS: ${CORS_ORIGINS:-["http://localhost"]}
DEBUG: "false"
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8000/health')\""]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "${FRONTEND_PORT:-80}:80"
depends_on:
backend:
condition: service_healthy
volumes:
pgdata:
+7
View File
@@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "all",
"plugins": ["prettier-plugin-tailwindcss"]
}
+20
View File
@@ -0,0 +1,20 @@
# ── Stage 1: build ──────────────────────────────────────────────────────────
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
COPY . .
RUN npm run build
# ── Stage 2: runtime ─────────────────────────────────────────────────────────
FROM nginx:1.27-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>
+50
View File
@@ -0,0 +1,50 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
# Static assets — long-lived cache with immutable flag
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API proxy → backend (preserve full /api/ prefix)
location /api/ {
proxy_pass http://backend:8000/api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
proxy_connect_timeout 10s;
}
# SPA fallback — all unknown routes serve index.html
location / {
try_files $uri $uri/ /index.html;
}
}
+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: {},
},
};
+34
View File
@@ -0,0 +1,34 @@
import { Routes, Route, Navigate } from "react-router-dom";
import Layout from "./components/Layout";
import ProtectedRoute from "./components/ProtectedRoute";
import LoginPage from "./pages/LoginPage";
import RegisterPage from "./pages/RegisterPage";
import DashboardPage from "./pages/DashboardPage";
import TransactionsPage from "./pages/TransactionsPage";
import CategoriesPage from "./pages/CategoriesPage";
import BudgetsPage from "./pages/BudgetsPage";
import HistoryPage from "./pages/HistoryPage";
export default function App() {
return (
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Protected routes with layout */}
<Route element={<ProtectedRoute />}>
<Route element={<Layout />}>
<Route path="/" element={<DashboardPage />} />
<Route path="/transactions" element={<TransactionsPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/budgets" element={<BudgetsPage />} />
<Route path="/history" element={<HistoryPage />} />
</Route>
</Route>
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
+307
View File
@@ -0,0 +1,307 @@
import axios, {
type InternalAxiosRequestConfig,
type AxiosResponse,
} from "axios";
import type {
AuthTokens,
User,
Transaction,
Category,
PaginatedResponse,
TransactionFilters,
CreateTransactionPayload,
UpdateTransactionPayload,
CreateCategoryPayload,
UpdateCategoryPayload,
DashboardData,
Budget,
CreateBudgetPayload,
UpdateBudgetPayload,
HistoryData,
} from "./types";
// ---------------------------------------------------------------------------
// Module-level token state (no circular dependency with auth store)
// ---------------------------------------------------------------------------
let _accessToken: string | null = null;
let _refreshToken: string | null = null;
let _onAuthFailed: (() => void) | null = null;
export function setClientTokens(
access: string | null,
refresh: string | null,
): void {
_accessToken = access;
_refreshToken = refresh;
}
export function setOnAuthFailed(cb: () => void): void {
_onAuthFailed = cb;
}
// ---------------------------------------------------------------------------
// Axios instance
// ---------------------------------------------------------------------------
export const apiClient = axios.create({
baseURL: "/api/v1",
headers: { "Content-Type": "application/json" },
});
// Request interceptor — attach Bearer token
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
if (_accessToken) {
config.headers.Authorization = `Bearer ${_accessToken}`;
}
return config;
});
// ---------------------------------------------------------------------------
// Response interceptor — silent token refresh on 401
// ---------------------------------------------------------------------------
let isRefreshing = false;
let failedQueue: Array<{
resolve: (token: string) => void;
reject: (err: unknown) => void;
}> = [];
function processQueue(error: unknown, token: string | null): void {
failedQueue.forEach((p) => {
if (error) p.reject(error);
else if (token) p.resolve(token);
});
failedQueue = [];
}
apiClient.interceptors.response.use(
(response: AxiosResponse) => response,
async (error: unknown) => {
if (!axios.isAxiosError(error)) return Promise.reject(error);
const config = error.config;
const status = error.response?.status;
if (status !== 401 || !config) return Promise.reject(error);
// Don't retry auth endpoints
if (
config.url?.includes("/auth/refresh") ||
config.url?.includes("/auth/login")
) {
return Promise.reject(error);
}
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then((token) => {
config.headers.Authorization = `Bearer ${token}`;
return apiClient(config);
});
}
isRefreshing = true;
try {
const rt = _refreshToken;
if (!rt) throw new Error("No refresh token");
const { data } = await axios.post<{ access_token: string }>(
"/api/v1/auth/refresh",
{ refresh_token: rt },
);
_accessToken = data.access_token;
processQueue(null, data.access_token);
config.headers.Authorization = `Bearer ${data.access_token}`;
return apiClient(config);
} catch (refreshError) {
processQueue(refreshError, null);
_accessToken = null;
_refreshToken = null;
_onAuthFailed?.();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
// ---------------------------------------------------------------------------
// Auth API
// ---------------------------------------------------------------------------
export async function loginUser(
email: string,
password: string,
): Promise<AuthTokens> {
const params = new URLSearchParams();
params.append("username", email);
params.append("password", password);
const { data } = await apiClient.post<AuthTokens>("/auth/login", params, {
headers: { "Content-Type": "application/x-www-form-urlencoded" },
});
return data;
}
export async function registerUser(
email: string,
password: string,
full_name: string,
): Promise<User> {
const { data } = await apiClient.post<User>("/auth/register", {
email,
password,
full_name,
});
return data;
}
export async function getMe(): Promise<User> {
const { data } = await apiClient.get<User>("/auth/me");
return data;
}
export async function logoutUser(refreshToken: string): Promise<void> {
await apiClient.post("/auth/logout", { refresh_token: refreshToken });
}
// ---------------------------------------------------------------------------
// Transactions API
// ---------------------------------------------------------------------------
export async function getTransactions(
filters: TransactionFilters,
): Promise<PaginatedResponse<Transaction>> {
const { data } = await apiClient.get<PaginatedResponse<Transaction>>(
"/transactions",
{ params: filters },
);
return data;
}
export async function createTransaction(
payload: CreateTransactionPayload,
): Promise<Transaction> {
const { data } = await apiClient.post<Transaction>("/transactions", payload);
return data;
}
export async function updateTransaction(
id: string,
payload: UpdateTransactionPayload,
): Promise<Transaction> {
const { data } = await apiClient.put<Transaction>(
`/transactions/${id}`,
payload,
);
return data;
}
export async function deleteTransaction(id: string): Promise<void> {
await apiClient.delete(`/transactions/${id}`);
}
// ---------------------------------------------------------------------------
// Categories API
// ---------------------------------------------------------------------------
export async function getCategories(
type?: string,
): Promise<Category[]> {
const { data } = await apiClient.get<Category[]>("/categories", {
params: type ? { type } : undefined,
});
return data;
}
export async function createCategory(
payload: CreateCategoryPayload,
): Promise<Category> {
const { data } = await apiClient.post<Category>("/categories", payload);
return data;
}
export async function updateCategory(
id: string,
payload: UpdateCategoryPayload,
): Promise<Category> {
const { data } = await apiClient.put<Category>(
`/categories/${id}`,
payload,
);
return data;
}
export async function deleteCategory(id: string): Promise<void> {
await apiClient.delete(`/categories/${id}`);
}
// ---------------------------------------------------------------------------
// Dashboard API
// ---------------------------------------------------------------------------
export async function getDashboard(month?: string): Promise<DashboardData> {
const { data } = await apiClient.get<DashboardData>("/dashboard", {
params: month ? { month } : undefined,
});
return data;
}
// ---------------------------------------------------------------------------
// Budgets API
// ---------------------------------------------------------------------------
export async function getBudgets(month?: string): Promise<Budget[]> {
const { data } = await apiClient.get<Budget[]>("/budgets", {
params: month ? { month } : undefined,
});
return data;
}
export async function createBudget(payload: CreateBudgetPayload): Promise<Budget> {
const { data } = await apiClient.post<Budget>("/budgets", payload);
return data;
}
export async function updateBudget(id: string, payload: UpdateBudgetPayload): Promise<Budget> {
const { data } = await apiClient.put<Budget>(`/budgets/${id}`, payload);
return data;
}
export async function deleteBudget(id: string): Promise<void> {
await apiClient.delete(`/budgets/${id}`);
}
export async function rolloverBudgets(month: string): Promise<Budget[]> {
const { data } = await apiClient.post<Budget[]>("/budgets/rollover", null, {
params: { month },
});
return data;
}
// ---------------------------------------------------------------------------
// History API
// ---------------------------------------------------------------------------
export async function getHistory(year?: number): Promise<HistoryData> {
const { data } = await apiClient.get<HistoryData>("/history", {
params: year ? { year } : undefined,
});
return data;
}
// ---------------------------------------------------------------------------
// Export API (returns blob URL)
// ---------------------------------------------------------------------------
export function exportCsvUrl(month: string): string {
return `/api/v1/export/csv?month=${month}`;
}
export function exportPdfUrl(month: string): string {
return `/api/v1/export/pdf?month=${month}`;
}
export async function downloadExport(url: string, filename: string): Promise<void> {
const { data } = await apiClient.get<Blob>(url.replace("/api/v1", ""), {
responseType: "blob",
});
const objectUrl = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = objectUrl;
a.download = filename;
a.click();
URL.revokeObjectURL(objectUrl);
}
+155
View File
@@ -0,0 +1,155 @@
export type TransactionType = "income" | "expense";
export type CategoryType = "income" | "expense" | "both";
export interface User {
id: string;
email: string;
full_name: string;
}
export interface AuthTokens {
access_token: string;
refresh_token: string;
token_type: string;
}
export interface CategoryBrief {
id: string;
name: string;
color: string | null;
}
export interface Transaction {
id: string;
amount_cents: number;
type: TransactionType;
description: string | null;
category: CategoryBrief;
transaction_date: string;
created_at: string;
}
export interface Category {
id: string;
name: string;
type: CategoryType;
color: string | null;
icon: string | null;
is_default: boolean;
created_at: string;
}
export interface PaginatedResponse<T> {
items: T[];
total: number;
page: number;
per_page: number;
}
export interface TransactionFilters {
month?: string;
category_id?: string;
type?: TransactionType;
page?: number;
per_page?: number;
}
export interface CreateTransactionPayload {
amount_cents: number;
type: TransactionType;
category_id: string;
description?: string;
transaction_date: string;
}
export interface UpdateTransactionPayload {
amount_cents?: number;
type?: TransactionType;
category_id?: string | null;
description?: string | null;
transaction_date?: string;
}
export interface CreateCategoryPayload {
name: string;
type: CategoryType;
color?: string;
icon?: string;
}
export interface UpdateCategoryPayload {
name?: string;
color?: string;
icon?: string;
}
// Dashboard
export interface CategoryExpense {
category_id: string;
category_name: string;
color: string | null;
amount_cents: number;
}
export interface MonthlyTrend {
month: string;
income_cents: number;
expense_cents: number;
}
export interface BudgetAlert {
budget_id: string;
category_name: string;
limit_cents: number;
spent_cents: number;
percentage: number;
}
export interface DashboardData {
month: string;
balance_cents: number;
total_income_cents: number;
total_expense_cents: number;
net_cents: number;
by_category: CategoryExpense[];
monthly_trend: MonthlyTrend[];
budget_alerts: BudgetAlert[];
}
// Budgets
export interface Budget {
id: string;
category_id: string;
category_name: string;
category_color: string | null;
month: string;
limit_cents: number;
spent_cents: number;
remaining_cents: number;
created_at: string;
updated_at: string;
}
export interface CreateBudgetPayload {
category_id: string;
month: string;
limit_cents: number;
}
export interface UpdateBudgetPayload {
limit_cents: number;
}
// History
export interface MonthSummary {
month: string;
income_cents: number;
expense_cents: number;
balance_cents: number;
transaction_count: number;
}
export interface HistoryData {
year: number;
months: MonthSummary[];
}
View File
+256
View File
@@ -0,0 +1,256 @@
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { useCreateCategory, useUpdateCategory } from "../hooks/useCategories";
import { useToast } from "../context/ToastContext";
import type { Category, CategoryType } from "../api/types";
interface Props {
isOpen: boolean;
onClose: () => void;
category?: Category;
}
interface FormState {
name: string;
type: CategoryType;
color: string;
icon: string;
}
const DEFAULT_FORM: FormState = {
name: "",
type: "expense",
color: "#6366f1",
icon: "",
};
export default function CategoryModal({ isOpen, onClose, category }: Props) {
const createMutation = useCreateCategory();
const updateMutation = useUpdateCategory();
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const isEditing = !!category;
const isDefault = category?.is_default ?? false;
const isPending = createMutation.isPending || updateMutation.isPending;
useEffect(() => {
if (category) {
setForm({
name: category.name,
type: category.type,
color: category.color ?? "#6366f1",
icon: category.icon ?? "",
});
} else {
setForm(DEFAULT_FORM);
}
setErrors({});
}, [category, isOpen]);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [key]: value }));
if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
}
function validate(): boolean {
const errs: Partial<Record<keyof FormState, string>> = {};
if (!form.name.trim()) errs.name = "Nom requis";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
if (isEditing && category) {
updateMutation.mutate(
{
id: category.id,
payload: {
name: form.name.trim(),
color: form.color,
icon: form.icon || undefined,
},
},
{
onSuccess: () => {
addToast({ type: "success", message: "Catégorie modifiée" });
onClose();
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de la modification" }),
},
);
} else {
createMutation.mutate(
{
name: form.name.trim(),
type: form.type,
color: form.color,
icon: form.icon || undefined,
},
{
onSuccess: () => {
addToast({ type: "success", message: "Catégorie créée" });
onClose();
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de la création" }),
},
);
}
}
if (!isOpen) return null;
const CATEGORY_TYPES: { value: CategoryType; label: string }[] = [
{ value: "expense", label: "Dépense" },
{ value: "income", label: "Revenu" },
{ value: "both", label: "Les deux" },
];
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b px-5 py-4">
<h2 className="text-base font-semibold text-slate-800">
{isEditing ? "Modifier la catégorie" : "Nouvelle catégorie"}
</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-700"
aria-label="Fermer"
>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-5">
{isDefault && (
<p className="rounded-lg bg-amber-50 px-3 py-2 text-xs text-amber-700">
Les catégories par défaut ne peuvent pas être modifiées en type.
</p>
)}
{/* Name */}
<div>
<label
htmlFor="cat-name"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Nom
</label>
<input
id="cat-name"
type="text"
value={form.name}
onChange={(e) => set("name", e.target.value)}
placeholder="Ex. : Alimentation"
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.name ? "border-red-400" : "border-slate-200"
}`}
/>
{errors.name && (
<p className="mt-1 text-xs text-red-500">{errors.name}</p>
)}
</div>
{/* Type */}
<div>
<label
htmlFor="cat-type"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Type
</label>
<select
id="cat-type"
value={form.type}
onChange={(e) => set("type", e.target.value as CategoryType)}
disabled={isDefault || isEditing}
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 disabled:bg-slate-50 disabled:text-slate-400"
>
{CATEGORY_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
</div>
{/* Color */}
<div>
<label
htmlFor="cat-color"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Couleur
</label>
<div className="flex items-center gap-3">
<input
id="cat-color"
type="color"
value={form.color}
onChange={(e) => set("color", e.target.value)}
className="h-9 w-14 cursor-pointer rounded border border-slate-200 p-0.5"
/>
<span className="text-sm text-slate-500">{form.color}</span>
</div>
</div>
{/* Icon */}
<div>
<label
htmlFor="cat-icon"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Icône{" "}
<span className="font-normal text-slate-400">(optionnel)</span>
</label>
<input
id="cat-icon"
type="text"
value={form.icon}
onChange={(e) => set("icon", e.target.value)}
placeholder="Ex. : shopping-cart"
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{isPending
? "Enregistrement…"
: isEditing
? "Modifier"
: "Créer"}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { AlertTriangle } from "lucide-react";
interface Props {
isOpen: boolean;
message: string;
isLoading?: boolean;
onConfirm: () => void;
onClose: () => void;
}
export default function DeleteConfirmModal({
isOpen,
message,
isLoading,
onConfirm,
onClose,
}: Props) {
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-red-100">
<AlertTriangle size={20} className="text-red-600" />
</div>
<h2 className="text-base font-semibold text-slate-800">
Confirmer la suppression
</h2>
</div>
<p className="mb-6 text-sm text-slate-600">{message}</p>
<div className="flex justify-end gap-3">
<button
onClick={onClose}
disabled={isLoading}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
Annuler
</button>
<button
onClick={onConfirm}
disabled={isLoading}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700 disabled:opacity-50"
>
{isLoading ? "Suppression…" : "Supprimer"}
</button>
</div>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useState } from "react";
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import {
LayoutDashboard,
ArrowLeftRight,
Tag,
Wallet,
History,
Menu,
X,
LogOut,
ChevronRight,
} from "lucide-react";
import { useAuthStore } from "../stores/auth";
import { logoutUser } from "../api/client";
import { useToast } from "../context/ToastContext";
const NAV_ITEMS = [
{ to: "/", icon: LayoutDashboard, label: "Dashboard", end: true },
{ to: "/transactions", icon: ArrowLeftRight, label: "Transactions", end: false },
{ to: "/categories", icon: Tag, label: "Catégories", end: false },
{ to: "/budgets", icon: Wallet, label: "Budgets", end: false },
{ to: "/history", icon: History, label: "Historique", end: false },
];
export default function Layout() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const { user, refreshToken, logout } = useAuthStore();
const navigate = useNavigate();
const { addToast } = useToast();
const handleLogout = async () => {
try {
if (refreshToken) await logoutUser(refreshToken);
} catch {
// best-effort logout
} finally {
logout();
navigate("/login");
addToast({ type: "info", message: "Déconnecté avec succès" });
}
};
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
isActive
? "bg-primary text-white"
: "text-slate-300 hover:bg-slate-700 hover:text-white"
}`;
const SidebarContent = () => (
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-16 items-center border-b border-slate-700 px-4">
<span className="text-lg font-bold text-white">Budget Tracker</span>
</div>
{/* Navigation */}
<nav className="flex-1 overflow-y-auto px-3 py-4">
<ul className="space-y-1">
{NAV_ITEMS.map(({ to, icon: Icon, label, end }) => (
<li key={to}>
<NavLink
to={to}
end={end}
className={navLinkClass}
onClick={() => setSidebarOpen(false)}
>
<Icon size={18} />
{label}
<ChevronRight size={14} className="ml-auto opacity-40" />
</NavLink>
</li>
))}
</ul>
</nav>
{/* User / Logout */}
<div className="border-t border-slate-700 p-4">
<div className="mb-2 truncate text-xs text-slate-400">
{user?.full_name ?? user?.email ?? "—"}
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm text-slate-300 transition-colors hover:bg-slate-700 hover:text-white"
>
<LogOut size={16} />
Déconnexion
</button>
</div>
</div>
);
return (
<div className="flex h-screen overflow-hidden">
{/* Desktop sidebar */}
<aside className="hidden w-60 shrink-0 bg-slate-800 lg:flex lg:flex-col">
<SidebarContent />
</aside>
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Mobile sidebar */}
<aside
className={`fixed inset-y-0 left-0 z-50 w-60 transform bg-slate-800 transition-transform duration-200 lg:hidden ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
}`}
>
<button
onClick={() => setSidebarOpen(false)}
className="absolute right-3 top-4 text-slate-400 hover:text-white"
aria-label="Fermer le menu"
>
<X size={20} />
</button>
<SidebarContent />
</aside>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Mobile header */}
<header className="flex h-14 items-center border-b bg-white px-4 lg:hidden">
<button
onClick={() => setSidebarOpen(true)}
className="text-slate-600 hover:text-slate-900"
aria-label="Ouvrir le menu"
>
<Menu size={22} />
</button>
<span className="ml-3 font-semibold text-slate-800">
Budget Tracker
</span>
</header>
{/* Page content */}
<main className="flex-1 overflow-y-auto bg-gray-50 p-4 lg:p-6">
<Outlet />
</main>
</div>
</div>
);
}
@@ -0,0 +1,8 @@
import { Navigate, Outlet } from "react-router-dom";
import { useAuthStore } from "../stores/auth";
export default function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (!isAuthenticated) return <Navigate to="/login" replace />;
return <Outlet />;
}
+302
View File
@@ -0,0 +1,302 @@
import { useEffect, useState } from "react";
import { X } from "lucide-react";
import { useCategories } from "../hooks/useCategories";
import {
useCreateTransaction,
useUpdateTransaction,
} from "../hooks/useTransactions";
import { eurosToCents, centsToEuros } from "../utils/format";
import { useToast } from "../context/ToastContext";
import type { Transaction, TransactionType } from "../api/types";
interface Props {
isOpen: boolean;
onClose: () => void;
transaction?: Transaction;
}
interface FormState {
amount: string;
type: TransactionType;
category_id: string;
description: string;
transaction_date: string;
}
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
const DEFAULT_FORM: FormState = {
amount: "",
type: "expense",
category_id: "",
description: "",
transaction_date: todayISO(),
};
export default function TransactionForm({ isOpen, onClose, transaction }: Props) {
const { data: categories = [] } = useCategories();
const createMutation = useCreateTransaction();
const updateMutation = useUpdateTransaction();
const { addToast } = useToast();
const [form, setForm] = useState<FormState>(DEFAULT_FORM);
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({});
const isEditing = !!transaction;
const isPending = createMutation.isPending || updateMutation.isPending;
// Populate form when editing
useEffect(() => {
if (transaction) {
setForm({
amount: String(centsToEuros(transaction.amount_cents)),
type: transaction.type,
category_id: transaction.category.id,
description: transaction.description ?? "",
transaction_date: transaction.transaction_date,
});
} else {
setForm(DEFAULT_FORM);
}
setErrors({});
}, [transaction, isOpen]);
const filteredCategories = categories.filter(
(c) => c.type === form.type || c.type === "both",
);
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((prev) => {
const next = { ...prev, [key]: value };
// Reset category when type changes and current category is incompatible
if (key === "type") {
const cat = categories.find((c) => c.id === prev.category_id);
if (cat && cat.type !== "both" && cat.type !== value) {
next.category_id = "";
}
}
return next;
});
if (errors[key]) setErrors((e) => ({ ...e, [key]: undefined }));
}
function validate(): boolean {
const errs: Partial<Record<keyof FormState, string>> = {};
const amountNum = parseFloat(form.amount);
if (!form.amount || isNaN(amountNum) || amountNum <= 0)
errs.amount = "Montant invalide (doit être > 0)";
if (!form.category_id) errs.category_id = "Catégorie requise";
if (!form.transaction_date) errs.transaction_date = "Date requise";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
const payload = {
amount_cents: eurosToCents(parseFloat(form.amount)),
type: form.type,
category_id: form.category_id,
description: form.description || undefined,
transaction_date: form.transaction_date,
};
if (isEditing && transaction) {
updateMutation.mutate(
{ id: transaction.id, payload },
{
onSuccess: () => {
addToast({ type: "success", message: "Transaction modifiée" });
onClose();
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de la modification" }),
},
);
} else {
createMutation.mutate(payload, {
onSuccess: () => {
addToast({ type: "success", message: "Transaction ajoutée" });
onClose();
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de l'ajout" }),
});
}
}
if (!isOpen) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="w-full max-w-md rounded-xl bg-white shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between border-b px-5 py-4">
<h2 className="text-base font-semibold text-slate-800">
{isEditing ? "Modifier la transaction" : "Nouvelle transaction"}
</h2>
<button
onClick={onClose}
className="text-slate-400 hover:text-slate-700"
aria-label="Fermer"
>
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-5">
{/* Type toggle */}
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">
Type
</label>
<div className="flex rounded-lg border border-slate-200 p-1">
{(["expense", "income"] as TransactionType[]).map((t) => (
<button
key={t}
type="button"
onClick={() => set("type", t)}
className={`flex-1 rounded-md py-1.5 text-sm font-medium transition-colors ${
form.type === t
? t === "expense"
? "bg-red-100 text-red-700"
: "bg-green-100 text-green-700"
: "text-slate-500 hover:text-slate-700"
}`}
>
{t === "expense" ? "Dépense" : "Revenu"}
</button>
))}
</div>
</div>
{/* Amount */}
<div>
<label
htmlFor="tx-amount"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Montant ()
</label>
<input
id="tx-amount"
type="number"
step="0.01"
min="0.01"
value={form.amount}
onChange={(e) => set("amount", e.target.value)}
placeholder="0,00"
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.amount ? "border-red-400" : "border-slate-200"
}`}
/>
{errors.amount && (
<p className="mt-1 text-xs text-red-500">{errors.amount}</p>
)}
</div>
{/* Date */}
<div>
<label
htmlFor="tx-date"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Date
</label>
<input
id="tx-date"
type="date"
value={form.transaction_date}
onChange={(e) => set("transaction_date", e.target.value)}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.transaction_date ? "border-red-400" : "border-slate-200"
}`}
/>
{errors.transaction_date && (
<p className="mt-1 text-xs text-red-500">{errors.transaction_date}</p>
)}
</div>
{/* Category */}
<div>
<label
htmlFor="tx-category"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Catégorie
</label>
<select
id="tx-category"
value={form.category_id}
onChange={(e) => set("category_id", e.target.value)}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.category_id ? "border-red-400" : "border-slate-200"
}`}
>
<option value=""> Choisir une catégorie </option>
{filteredCategories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{errors.category_id && (
<p className="mt-1 text-xs text-red-500">{errors.category_id}</p>
)}
</div>
{/* Description */}
<div>
<label
htmlFor="tx-description"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Description{" "}
<span className="font-normal text-slate-400">(optionnel)</span>
</label>
<input
id="tx-description"
type="text"
value={form.description}
onChange={(e) => set("description", e.target.value)}
placeholder="Ex. : Courses Carrefour"
className="w-full rounded-lg border border-slate-200 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
</div>
{/* Actions */}
<div className="flex justify-end gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50"
>
Annuler
</button>
<button
type="submit"
disabled={isPending}
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{isPending
? "Enregistrement…"
: isEditing
? "Modifier"
: "Ajouter"}
</button>
</div>
</form>
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import {
createContext,
useCallback,
useContext,
useReducer,
useEffect,
} from "react";
import { X } from "lucide-react";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ToastType = "success" | "error" | "info";
interface Toast {
id: number;
type: ToastType;
message: string;
}
interface ToastContextValue {
addToast: (toast: Omit<Toast, "id">) => void;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
const ToastContext = createContext<ToastContextValue | null>(null);
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error("useToast must be used within ToastProvider");
return ctx;
}
// ---------------------------------------------------------------------------
// Reducer
// ---------------------------------------------------------------------------
type Action =
| { type: "ADD"; toast: Toast }
| { type: "REMOVE"; id: number };
function reducer(state: Toast[], action: Action): Toast[] {
switch (action.type) {
case "ADD":
return [...state, action.toast];
case "REMOVE":
return state.filter((t) => t.id !== action.id);
}
}
// ---------------------------------------------------------------------------
// Provider + UI
// ---------------------------------------------------------------------------
let nextId = 0;
function ToastItem({
toast,
onRemove,
}: {
toast: Toast;
onRemove: (id: number) => void;
}) {
useEffect(() => {
const timer = setTimeout(() => onRemove(toast.id), 4000);
return () => clearTimeout(timer);
}, [toast.id, onRemove]);
const colors: Record<ToastType, string> = {
success: "bg-green-600",
error: "bg-red-600",
info: "bg-blue-600",
};
return (
<div
className={`flex items-center gap-3 rounded-lg px-4 py-3 text-sm text-white shadow-lg ${colors[toast.type]}`}
>
<span className="flex-1">{toast.message}</span>
<button
onClick={() => onRemove(toast.id)}
className="shrink-0 opacity-80 hover:opacity-100"
aria-label="Fermer"
>
<X size={14} />
</button>
</div>
);
}
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, dispatch] = useReducer(reducer, []);
const removeToast = useCallback((id: number) => {
dispatch({ type: "REMOVE", id });
}, []);
const addToast = useCallback((toast: Omit<Toast, "id">) => {
dispatch({ type: "ADD", toast: { ...toast, id: ++nextId } });
}, []);
return (
<ToastContext.Provider value={{ addToast }}>
{children}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onRemove={removeToast} />
))}
</div>
</ToastContext.Provider>
);
}
View File
+49
View File
@@ -0,0 +1,49 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createBudget,
deleteBudget,
getBudgets,
rolloverBudgets,
updateBudget,
} from "../api/client";
import type { CreateBudgetPayload, UpdateBudgetPayload } from "../api/types";
export function useBudgets(month?: string) {
return useQuery({
queryKey: ["budgets", month],
queryFn: () => getBudgets(month),
});
}
export function useCreateBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateBudgetPayload) => createBudget(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
export function useUpdateBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateBudgetPayload }) =>
updateBudget(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
export function useDeleteBudget() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteBudget(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
export function useRolloverBudgets() {
const qc = useQueryClient();
return useMutation({
mutationFn: (month: string) => rolloverBudgets(month),
onSuccess: () => qc.invalidateQueries({ queryKey: ["budgets"] }),
});
}
+43
View File
@@ -0,0 +1,43 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createCategory,
deleteCategory,
getCategories,
updateCategory,
} from "../api/client";
import type {
CreateCategoryPayload,
UpdateCategoryPayload,
} from "../api/types";
export function useCategories() {
return useQuery({
queryKey: ["categories"],
queryFn: () => getCategories(),
});
}
export function useCreateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateCategoryPayload) => createCategory(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
});
}
export function useUpdateCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({ id, payload }: { id: string; payload: UpdateCategoryPayload }) =>
updateCategory(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
});
}
export function useDeleteCategory() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteCategory(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["categories"] }),
});
}
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getDashboard } from "../api/client";
export function useDashboard(month?: string) {
return useQuery({
queryKey: ["dashboard", month],
queryFn: () => getDashboard(month),
});
}
+9
View File
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getHistory } from "../api/client";
export function useHistory(year?: number) {
return useQuery({
queryKey: ["history", year],
queryFn: () => getHistory(year),
});
}
+50
View File
@@ -0,0 +1,50 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createTransaction,
deleteTransaction,
getTransactions,
updateTransaction,
} from "../api/client";
import type {
CreateTransactionPayload,
TransactionFilters,
UpdateTransactionPayload,
} from "../api/types";
export function useTransactions(filters: TransactionFilters) {
return useQuery({
queryKey: ["transactions", filters],
queryFn: () => getTransactions(filters),
});
}
export function useCreateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (payload: CreateTransactionPayload) =>
createTransaction(payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
}
export function useUpdateTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: ({
id,
payload,
}: {
id: string;
payload: UpdateTransactionPayload;
}) => updateTransaction(id, payload),
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
}
export function useDeleteTransaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteTransaction(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ["transactions"] }),
});
}
+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>,
);
+316
View File
@@ -0,0 +1,316 @@
import { useState } from "react";
import { ChevronLeft, ChevronRight, Plus, Pencil, Trash2, CopyPlus } from "lucide-react";
import {
useBudgets,
useCreateBudget,
useUpdateBudget,
useDeleteBudget,
useRolloverBudgets,
} from "../hooks/useBudgets";
import { useCategories } from "../hooks/useCategories";
import DeleteConfirmModal from "../components/DeleteConfirmModal";
import { useToast } from "../context/ToastContext";
import { formatCurrency, currentMonth } from "../utils/format";
import type { Budget } from "../api/types";
function prevMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
if (m === 1) return `${y - 1}-12`;
return `${y}-${String(m - 1).padStart(2, "0")}`;
}
function nextMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
if (m === 12) return `${y + 1}-01`;
return `${y}-${String(m + 1).padStart(2, "0")}`;
}
function formatMonthLabel(month: string): string {
const [y, m] = month.split("-").map(Number);
return new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric" }).format(
new Date(y, m - 1, 1)
);
}
function ProgressBar({ spent, limit }: { spent: number; limit: number }) {
const pct = limit > 0 ? Math.min((spent / limit) * 100, 100) : 0;
const color =
pct >= 100 ? "bg-red-500" : pct >= 70 ? "bg-orange-400" : "bg-green-500";
return (
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100">
<div className={`h-full rounded-full transition-all ${color}`} style={{ width: `${pct}%` }} />
</div>
);
}
interface BudgetFormProps {
month: string;
editing?: Budget;
onClose: () => void;
}
function BudgetForm({ month, editing, onClose }: BudgetFormProps) {
const { data: categoriesData } = useCategories();
const createBudget = useCreateBudget();
const updateBudget = useUpdateBudget();
const { addToast } = useToast();
const expenseCategories = categoriesData?.filter(
(c) => c.type === "expense" || c.type === "both"
) ?? [];
const [categoryId, setCategoryId] = useState(editing?.category_id ?? "");
const [limitEuros, setLimitEuros] = useState(
editing ? String(editing.limit_cents / 100) : ""
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const limit_cents = Math.round(Number(limitEuros) * 100);
if (!limit_cents || limit_cents <= 0) {
addToast({ type: "error", message: "Limite invalide" });
return;
}
try {
if (editing) {
await updateBudget.mutateAsync({ id: editing.id, payload: { limit_cents } });
addToast({ type: "success", message: "Budget modifié" });
} else {
if (!categoryId) {
addToast({ type: "error", message: "Sélectionnez une catégorie" });
return;
}
await createBudget.mutateAsync({ category_id: categoryId, month, limit_cents });
addToast({ type: "success", message: "Budget créé" });
}
onClose();
} catch {
addToast({ type: "error", message: "Erreur lors de la sauvegarde" });
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-sm rounded-xl bg-white p-6 shadow-xl">
<h2 className="mb-4 text-lg font-semibold">
{editing ? "Modifier le budget" : "Nouveau budget"}
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
{!editing && (
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">
Catégorie
</label>
<select
value={categoryId}
onChange={(e) => setCategoryId(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
required
>
<option value="">Sélectionner</option>
{expenseCategories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-slate-700">
Limite ()
</label>
<input
type="number"
min="0.01"
step="0.01"
value={limitEuros}
onChange={(e) => setLimitEuros(e.target.value)}
className="w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-400"
placeholder="ex: 500"
required
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="rounded-lg px-4 py-2 text-sm text-slate-600 hover:bg-slate-100"
>
Annuler
</button>
<button
type="submit"
className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
{editing ? "Enregistrer" : "Créer"}
</button>
</div>
</form>
</div>
</div>
);
}
export default function BudgetsPage() {
const [month, setMonth] = useState(currentMonth());
const { data: budgets, isLoading } = useBudgets(month);
const deleteBudget = useDeleteBudget();
const rollover = useRolloverBudgets();
const { addToast } = useToast();
const [formOpen, setFormOpen] = useState(false);
const [editingBudget, setEditingBudget] = useState<Budget | undefined>();
const [deleteTarget, setDeleteTarget] = useState<Budget | undefined>();
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteBudget.mutateAsync(deleteTarget.id);
addToast({ type: "success", message: "Budget supprimé" });
} catch {
addToast({ type: "error", message: "Erreur lors de la suppression" });
} finally {
setDeleteTarget(undefined);
}
};
const handleRollover = async () => {
try {
const created = await rollover.mutateAsync(month);
if (created.length === 0) {
addToast({ type: "info", message: "Aucun budget à reconduire (déjà existants)" });
} else {
addToast({ type: "success", message: `${created.length} budget(s) reconduit(s)` });
}
} catch {
addToast({ type: "error", message: "Erreur lors de la reconduction" });
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<button
onClick={() => setMonth(prevMonth(month))}
className="rounded-lg border p-2 hover:bg-slate-100"
>
<ChevronLeft size={16} />
</button>
<h1 className="text-xl font-semibold text-slate-800 capitalize">
{formatMonthLabel(month)}
</h1>
<button
onClick={() => setMonth(nextMonth(month))}
className="rounded-lg border p-2 hover:bg-slate-100"
>
<ChevronRight size={16} />
</button>
</div>
<div className="flex gap-2">
<button
onClick={handleRollover}
disabled={rollover.isPending}
className="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm text-slate-700 hover:bg-slate-50 disabled:opacity-50"
>
<CopyPlus size={15} />
Reconduire M-1
</button>
<button
onClick={() => { setEditingBudget(undefined); setFormOpen(true); }}
className="flex items-center gap-1.5 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
<Plus size={15} />
Nouveau budget
</button>
</div>
</div>
{/* Budget list */}
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="h-24 animate-pulse rounded-xl bg-slate-100" />
))}
</div>
) : !budgets || budgets.length === 0 ? (
<div className="rounded-xl border bg-white p-10 text-center text-slate-400">
Aucun budget défini pour ce mois.
</div>
) : (
<div className="space-y-3">
{budgets.map((budget) => {
const pct = budget.limit_cents > 0
? Math.round((budget.spent_cents / budget.limit_cents) * 100)
: 0;
const over = budget.spent_cents > budget.limit_cents;
return (
<div
key={budget.id}
className="rounded-xl border bg-white p-4 shadow-sm"
>
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2">
{budget.category_color && (
<span
className="h-3 w-3 rounded-full"
style={{ background: budget.category_color }}
/>
)}
<span className="font-medium text-slate-800">
{budget.category_name}
</span>
{over && (
<span className="rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700">
Dépassé
</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => { setEditingBudget(budget); setFormOpen(true); }}
className="rounded p-1 text-slate-400 hover:text-slate-700"
>
<Pencil size={14} />
</button>
<button
onClick={() => setDeleteTarget(budget)}
className="rounded p-1 text-slate-400 hover:text-red-600"
>
<Trash2 size={14} />
</button>
</div>
</div>
<ProgressBar spent={budget.spent_cents} limit={budget.limit_cents} />
<div className="mt-1.5 flex justify-between text-xs text-slate-500">
<span>{formatCurrency(budget.spent_cents)} dépensés</span>
<span className={over ? "font-semibold text-red-600" : ""}>
{pct}% limite {formatCurrency(budget.limit_cents)}
</span>
</div>
</div>
);
})}
</div>
)}
{/* Form modal */}
{formOpen && (
<BudgetForm
month={month}
editing={editingBudget}
onClose={() => { setFormOpen(false); setEditingBudget(undefined); }}
/>
)}
{/* Delete confirm */}
{deleteTarget && (
<DeleteConfirmModal
message={`Supprimer le budget "${deleteTarget.category_name}" ?`}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(undefined)}
/>
)}
</div>
);
}
+121
View File
@@ -0,0 +1,121 @@
import { useState } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { useCategories, useDeleteCategory } from "../hooks/useCategories";
import CategoryModal from "../components/CategoryModal";
import DeleteConfirmModal from "../components/DeleteConfirmModal";
import { useToast } from "../context/ToastContext";
import type { Category } from "../api/types";
export default function CategoriesPage() {
const { data: categories, isLoading } = useCategories();
const deleteMutation = useDeleteCategory();
const { addToast } = useToast();
const [modalOpen, setModalOpen] = useState(false);
const [editingCat, setEditingCat] = useState<Category | undefined>();
const [deleteTarget, setDeleteTarget] = useState<Category | undefined>();
function openCreate() {
setEditingCat(undefined);
setModalOpen(true);
}
function handleDelete() {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
addToast({ type: "success", message: "Catégorie supprimée" });
setDeleteTarget(undefined);
},
onError: () => addToast({ type: "error", message: "Erreur lors de la suppression" }),
});
}
const expense = categories?.filter((c) => c.type === "expense" || c.type === "both") ?? [];
const income = categories?.filter((c) => c.type === "income") ?? [];
function CategoryRow({ cat }: { cat: Category }) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-white px-4 py-3 shadow-sm">
{cat.color && (
<span className="h-4 w-4 rounded-full shrink-0" style={{ background: cat.color }} />
)}
<span className="flex-1 text-sm font-medium text-slate-800">{cat.name}</span>
{cat.is_default && (
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-xs text-slate-500">
Défaut
</span>
)}
<button
onClick={() => { setEditingCat(cat); setModalOpen(true); }}
className="p-1 text-slate-400 hover:text-slate-700"
>
<Pencil size={14} />
</button>
<button
onClick={() => setDeleteTarget(cat)}
className="p-1 text-slate-400 hover:text-red-600"
disabled={cat.is_default}
>
<Trash2 size={14} />
</button>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-slate-800">Catégories</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-700"
>
<Plus size={16} />
Nouvelle
</button>
</div>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-12 animate-pulse rounded-lg bg-slate-100" />
))}
</div>
) : (
<>
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
Dépenses
</h2>
<div className="space-y-2">
{expense.map((c) => <CategoryRow key={c.id} cat={c} />)}
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-semibold uppercase tracking-wide text-slate-500">
Revenus
</h2>
<div className="space-y-2">
{income.map((c) => <CategoryRow key={c.id} cat={c} />)}
</div>
</section>
</>
)}
<CategoryModal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
category={editingCat}
/>
{deleteTarget && (
<DeleteConfirmModal
message={`Supprimer la catégorie "${deleteTarget.name}" ?`}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(undefined)}
/>
)}
</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
BarChart,
Bar,
PieChart,
Pie,
Cell,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import {
ChevronLeft,
ChevronRight,
TrendingUp,
TrendingDown,
Wallet,
AlertTriangle,
Download,
} from "lucide-react";
import { useDashboard } from "../hooks/useDashboard";
import { downloadExport } from "../api/client";
import { formatCurrency, currentMonth } from "../utils/format";
import { useToast } from "../context/ToastContext";
const PIE_COLORS = [
"#6366f1", "#f59e0b", "#10b981", "#ef4444", "#3b82f6",
"#8b5cf6", "#ec4899", "#14b8a6", "#f97316", "#84cc16",
];
function prevMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
if (m === 1) return `${y - 1}-12`;
return `${y}-${String(m - 1).padStart(2, "0")}`;
}
function nextMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
if (m === 12) return `${y + 1}-01`;
return `${y}-${String(m + 1).padStart(2, "0")}`;
}
function formatMonthLabel(month: string): string {
const [y, m] = month.split("-").map(Number);
return new Intl.DateTimeFormat("fr-FR", { month: "long", year: "numeric" }).format(
new Date(y, m - 1, 1)
);
}
function formatShortMonth(month: string): string {
const [y, m] = month.split("-").map(Number);
return new Intl.DateTimeFormat("fr-FR", { month: "short" }).format(new Date(y, m - 1, 1));
}
interface KpiCardProps {
label: string;
value: number;
icon: React.ReactNode;
color: string;
signed?: boolean;
}
function KpiCard({ label, value, icon, color, signed }: KpiCardProps) {
const formatted = signed
? (value >= 0 ? "+" : "") + formatCurrency(value)
: formatCurrency(value);
return (
<div className="rounded-xl border bg-white p-5 shadow-sm">
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">{label}</p>
<span className={`rounded-full p-2 ${color}`}>{icon}</span>
</div>
<p className={`mt-2 text-2xl font-bold ${signed && value < 0 ? "text-red-600" : signed && value > 0 ? "text-green-600" : "text-slate-900"}`}>
{formatted}
</p>
</div>
);
}
export default function DashboardPage() {
const [month, setMonth] = useState(currentMonth());
const { data, isLoading } = useDashboard(month);
const { addToast } = useToast();
const navigate = useNavigate();
const handleExportCsv = async () => {
try {
await downloadExport(`/api/v1/export/csv?month=${month}`, `transactions_${month}.csv`);
} catch {
addToast({ type: "error", message: "Erreur lors de l'export CSV" });
}
};
const handleExportPdf = async () => {
try {
await downloadExport(`/api/v1/export/pdf?month=${month}`, `transactions_${month}.pdf`);
} catch {
addToast({ type: "error", message: "Erreur lors de l'export PDF" });
}
};
const trendData = data?.monthly_trend.map((t) => ({
name: formatShortMonth(t.month),
Revenus: t.income_cents / 100,
Dépenses: t.expense_cents / 100,
})) ?? [];
const pieData = data?.by_category.map((c) => ({
name: c.category_name,
value: c.amount_cents / 100,
color: c.color ?? undefined,
})) ?? [];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<button
onClick={() => setMonth(prevMonth(month))}
className="rounded-lg border p-2 hover:bg-slate-100"
>
<ChevronLeft size={16} />
</button>
<h1 className="text-xl font-semibold text-slate-800 capitalize">
{formatMonthLabel(month)}
</h1>
<button
onClick={() => setMonth(nextMonth(month))}
className="rounded-lg border p-2 hover:bg-slate-100"
disabled={month >= currentMonth()}
>
<ChevronRight size={16} />
</button>
</div>
<div className="flex gap-2">
<button
onClick={handleExportCsv}
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-slate-50"
>
<Download size={14} /> CSV
</button>
<button
onClick={handleExportPdf}
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm hover:bg-slate-50"
>
<Download size={14} /> PDF
</button>
</div>
</div>
{/* Budget alerts */}
{data?.budget_alerts && data.budget_alerts.length > 0 && (
<div className="space-y-2">
{data.budget_alerts.map((alert) => (
<div
key={alert.budget_id}
className={`flex items-center gap-3 rounded-lg border px-4 py-3 text-sm ${
alert.percentage >= 100
? "border-red-200 bg-red-50 text-red-800"
: "border-orange-200 bg-orange-50 text-orange-800"
}`}
>
<AlertTriangle size={16} className="shrink-0" />
<span>
<strong>{alert.category_name}</strong> :{" "}
{formatCurrency(alert.spent_cents)} dépensés sur{" "}
{formatCurrency(alert.limit_cents)} ({alert.percentage}%)
{alert.percentage >= 100 && " — Budget dépassé !"}
</span>
<button
onClick={() => navigate("/budgets")}
className="ml-auto shrink-0 font-medium underline"
>
Gérer
</button>
</div>
))}
</div>
)}
{/* KPI Cards */}
{isLoading ? (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-28 animate-pulse rounded-xl bg-slate-100" />
))}
</div>
) : (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<KpiCard
label="Solde courant"
value={data?.balance_cents ?? 0}
icon={<Wallet size={18} className="text-indigo-600" />}
color="bg-indigo-50"
/>
<KpiCard
label="Revenus du mois"
value={data?.total_income_cents ?? 0}
icon={<TrendingUp size={18} className="text-green-600" />}
color="bg-green-50"
/>
<KpiCard
label="Dépenses du mois"
value={data?.total_expense_cents ?? 0}
icon={<TrendingDown size={18} className="text-red-600" />}
color="bg-red-50"
/>
<KpiCard
label="Solde net du mois"
value={data?.net_cents ?? 0}
icon={<TrendingUp size={18} className="text-blue-600" />}
color="bg-blue-50"
signed
/>
</div>
)}
{/* Charts */}
<div className="grid gap-6 lg:grid-cols-2">
{/* Bar chart — monthly trend */}
<div className="rounded-xl border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-slate-700">
Tendance 6 derniers mois
</h2>
{isLoading ? (
<div className="h-48 animate-pulse rounded-lg bg-slate-100" />
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={trendData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}`} />
<Tooltip formatter={(v: number) => `${v.toFixed(2)}`} />
<Legend />
<Bar dataKey="Revenus" fill="#10b981" radius={[4, 4, 0, 0]} />
<Bar dataKey="Dépenses" fill="#ef4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</div>
{/* Pie chart — expenses by category */}
<div className="rounded-xl border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-slate-700">
Dépenses par catégorie
</h2>
{isLoading ? (
<div className="h-48 animate-pulse rounded-lg bg-slate-100" />
) : pieData.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-slate-400">
Aucune dépense ce mois
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie
data={pieData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={80}
label={({ name, percent }) =>
`${name} ${(percent * 100).toFixed(0)}%`
}
labelLine={false}
>
{pieData.map((entry, index) => (
<Cell
key={entry.name}
fill={entry.color ?? PIE_COLORS[index % PIE_COLORS.length]}
/>
))}
</Pie>
<Tooltip formatter={(v: number) => `${v.toFixed(2)}`} />
</PieChart>
</ResponsiveContainer>
)}
</div>
</div>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { ChevronLeft, ChevronRight } from "lucide-react";
import { useHistory } from "../hooks/useHistory";
import { formatCurrency } from "../utils/format";
const MONTH_NAMES = [
"Jan", "Fév", "Mar", "Avr", "Mai", "Jun",
"Jul", "Aoû", "Sep", "Oct", "Nov", "Déc",
];
export default function HistoryPage() {
const currentYear = new Date().getFullYear();
const [year, setYear] = useState(currentYear);
const { data, isLoading } = useHistory(year);
const navigate = useNavigate();
const chartData = data?.months.map((m, i) => ({
name: MONTH_NAMES[i],
Revenus: m.income_cents / 100,
Dépenses: m.expense_cents / 100,
})) ?? [];
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<button
onClick={() => setYear((y) => y - 1)}
className="rounded-lg border p-2 hover:bg-slate-100"
>
<ChevronLeft size={16} />
</button>
<h1 className="text-xl font-semibold text-slate-800">{year}</h1>
<button
onClick={() => setYear((y) => y + 1)}
className="rounded-lg border p-2 hover:bg-slate-100"
disabled={year >= currentYear}
>
<ChevronRight size={16} />
</button>
</div>
{/* Line chart */}
<div className="rounded-xl border bg-white p-5 shadow-sm">
<h2 className="mb-4 text-sm font-semibold text-slate-700">
Revenus et dépenses {year}
</h2>
{isLoading ? (
<div className="h-52 animate-pulse rounded-lg bg-slate-100" />
) : (
<ResponsiveContainer width="100%" height={220}>
<LineChart data={chartData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} tickFormatter={(v) => `${v}`} />
<Tooltip formatter={(v: number) => `${v.toFixed(2)}`} />
<Legend />
<Line
type="monotone"
dataKey="Revenus"
stroke="#10b981"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="Dépenses"
stroke="#ef4444"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
{/* Monthly table */}
<div className="rounded-xl border bg-white shadow-sm">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-slate-50">
<th className="px-4 py-3 text-left font-semibold text-slate-600">Mois</th>
<th className="px-4 py-3 text-right font-semibold text-slate-600">Revenus</th>
<th className="px-4 py-3 text-right font-semibold text-slate-600">Dépenses</th>
<th className="px-4 py-3 text-right font-semibold text-slate-600">Solde net</th>
<th className="px-4 py-3 text-right font-semibold text-slate-600">Transactions</th>
</tr>
</thead>
<tbody>
{isLoading
? Array.from({ length: 12 }).map((_, i) => (
<tr key={i} className="border-b">
{Array.from({ length: 5 }).map((__, j) => (
<td key={j} className="px-4 py-3">
<div className="h-4 animate-pulse rounded bg-slate-100" />
</td>
))}
</tr>
))
: data?.months.map((m, i) => {
const hasData = m.transaction_count > 0;
return (
<tr
key={m.month}
className={`border-b last:border-0 transition-colors ${
hasData ? "cursor-pointer hover:bg-slate-50" : "opacity-50"
}`}
onClick={() => hasData && navigate(`/?month=${m.month}`)}
>
<td className="px-4 py-3 font-medium text-slate-800">
{MONTH_NAMES[i]}
</td>
<td className="px-4 py-3 text-right text-green-700">
{m.income_cents > 0 ? formatCurrency(m.income_cents) : "—"}
</td>
<td className="px-4 py-3 text-right text-red-600">
{m.expense_cents > 0 ? formatCurrency(m.expense_cents) : "—"}
</td>
<td
className={`px-4 py-3 text-right font-medium ${
m.balance_cents >= 0 ? "text-green-700" : "text-red-600"
}`}
>
{m.transaction_count > 0
? (m.balance_cents >= 0 ? "+" : "") + formatCurrency(m.balance_cents)
: "—"}
</td>
<td className="px-4 py-3 text-right text-slate-500">
{m.transaction_count > 0 ? m.transaction_count : "—"}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
+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>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { loginUser, getMe, setClientTokens } from "../api/client";
import { useAuthStore } from "../stores/auth";
interface FormState {
email: string;
password: string;
}
interface Errors {
email?: string;
password?: string;
global?: string;
}
export default function LoginPage() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [form, setForm] = useState<FormState>({ email: "", password: "" });
const [errors, setErrors] = useState<Errors>({});
const [isLoading, setIsLoading] = useState(false);
if (isAuthenticated) {
navigate("/", { replace: true });
return null;
}
function validate(): boolean {
const errs: Errors = {};
if (!form.email) errs.email = "Email requis";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
errs.email = "Email invalide";
if (!form.password) errs.password = "Mot de passe requis";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
setErrors({});
try {
const tokens = await loginUser(form.email, form.password);
// Set tokens on the client so getMe() succeeds
setClientTokens(tokens.access_token, tokens.refresh_token);
const user = await getMe();
login(user, tokens.access_token, tokens.refresh_token);
navigate("/", { replace: true });
} catch (err) {
const msg =
err instanceof Error && err.message.includes("401")
? "Email ou mot de passe incorrect"
: "Erreur de connexion, veuillez réessayer";
setErrors({ global: msg });
} finally {
setIsLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-slate-800">Budget Tracker</h1>
<p className="mt-1 text-sm text-slate-500">
Connectez-vous à votre compte
</p>
</div>
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
{errors.global && (
<div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
{errors.global}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label
htmlFor="email"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
value={form.email}
onChange={(e) =>
setForm((f) => ({ ...f, email: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.email ? "border-red-400" : "border-slate-200"
}`}
placeholder="vous@exemple.com"
/>
{errors.email && (
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
)}
</div>
<div>
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Mot de passe
</label>
<input
id="password"
type="password"
autoComplete="current-password"
value={form.password}
onChange={(e) =>
setForm((f) => ({ ...f, password: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.password ? "border-red-400" : "border-slate-200"
}`}
placeholder="••••••••"
/>
{errors.password && (
<p className="mt-1 text-xs text-red-500">{errors.password}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? "Connexion…" : "Se connecter"}
</button>
</form>
</div>
<p className="mt-4 text-center text-sm text-slate-500">
Pas encore de compte ?{" "}
<Link to="/register" className="font-medium text-primary hover:underline">
S&apos;inscrire
</Link>
</p>
</div>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { registerUser, loginUser, getMe, setClientTokens } from "../api/client";
import { useAuthStore } from "../stores/auth";
interface FormState {
full_name: string;
email: string;
password: string;
}
interface Errors {
full_name?: string;
email?: string;
password?: string;
global?: string;
}
export default function RegisterPage() {
const navigate = useNavigate();
const login = useAuthStore((s) => s.login);
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const [form, setForm] = useState<FormState>({
full_name: "",
email: "",
password: "",
});
const [errors, setErrors] = useState<Errors>({});
const [isLoading, setIsLoading] = useState(false);
if (isAuthenticated) {
navigate("/", { replace: true });
return null;
}
function validate(): boolean {
const errs: Errors = {};
if (!form.full_name.trim() || form.full_name.trim().length < 2)
errs.full_name = "Nom complet requis (2 caractères minimum)";
if (!form.email) errs.email = "Email requis";
else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))
errs.email = "Email invalide";
if (!form.password || form.password.length < 8)
errs.password = "Mot de passe requis (8 caractères minimum)";
setErrors(errs);
return Object.keys(errs).length === 0;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!validate()) return;
setIsLoading(true);
setErrors({});
try {
await registerUser(form.email, form.password, form.full_name.trim());
// Auto-login after registration
const tokens = await loginUser(form.email, form.password);
setClientTokens(tokens.access_token, tokens.refresh_token);
const user = await getMe();
login(user, tokens.access_token, tokens.refresh_token);
navigate("/", { replace: true });
} catch (err) {
const isConflict =
err instanceof Error && err.message.toLowerCase().includes("409");
setErrors({
global: isConflict
? "Cet email est déjà utilisé"
: "Erreur lors de l'inscription, veuillez réessayer",
});
} finally {
setIsLoading(false);
}
}
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 p-4">
<div className="w-full max-w-sm">
<div className="mb-8 text-center">
<h1 className="text-2xl font-bold text-slate-800">Budget Tracker</h1>
<p className="mt-1 text-sm text-slate-500">Créez votre compte</p>
</div>
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-slate-200">
{errors.global && (
<div className="mb-4 rounded-lg bg-red-50 px-3 py-2 text-sm text-red-600">
{errors.global}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div>
<label
htmlFor="full_name"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Nom complet
</label>
<input
id="full_name"
type="text"
autoComplete="name"
value={form.full_name}
onChange={(e) =>
setForm((f) => ({ ...f, full_name: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.full_name ? "border-red-400" : "border-slate-200"
}`}
placeholder="Jean Dupont"
/>
{errors.full_name && (
<p className="mt-1 text-xs text-red-500">{errors.full_name}</p>
)}
</div>
<div>
<label
htmlFor="email"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
value={form.email}
onChange={(e) =>
setForm((f) => ({ ...f, email: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.email ? "border-red-400" : "border-slate-200"
}`}
placeholder="vous@exemple.com"
/>
{errors.email && (
<p className="mt-1 text-xs text-red-500">{errors.email}</p>
)}
</div>
<div>
<label
htmlFor="password"
className="mb-1.5 block text-sm font-medium text-slate-700"
>
Mot de passe
</label>
<input
id="password"
type="password"
autoComplete="new-password"
value={form.password}
onChange={(e) =>
setForm((f) => ({ ...f, password: e.target.value }))
}
className={`w-full rounded-lg border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary/30 ${
errors.password ? "border-red-400" : "border-slate-200"
}`}
placeholder="8 caractères minimum"
/>
{errors.password && (
<p className="mt-1 text-xs text-red-500">{errors.password}</p>
)}
</div>
<button
type="submit"
disabled={isLoading}
className="w-full rounded-lg bg-primary py-2 text-sm font-medium text-white hover:bg-primary/90 disabled:opacity-50"
>
{isLoading ? "Inscription…" : "Créer mon compte"}
</button>
</form>
</div>
<p className="mt-4 text-center text-sm text-slate-500">
Déjà un compte ?{" "}
<Link to="/login" className="font-medium text-primary hover:underline">
Se connecter
</Link>
</p>
</div>
</div>
);
}
+342
View File
@@ -0,0 +1,342 @@
import { useState } from "react";
import { Plus, Pencil, Trash2, TrendingUp, TrendingDown, ChevronLeft, ChevronRight, Download } from "lucide-react";
import { downloadExport } from "../api/client";
import {
useTransactions,
useDeleteTransaction,
} from "../hooks/useTransactions";
import { useCategories } from "../hooks/useCategories";
import TransactionForm from "../components/TransactionForm";
import DeleteConfirmModal from "../components/DeleteConfirmModal";
import { useToast } from "../context/ToastContext";
import { formatCurrency, formatDate, currentMonth } from "../utils/format";
import type { Transaction, TransactionType } from "../api/types";
const PER_PAGE = 20;
// Skeleton row
function SkeletonRow() {
return (
<tr>
{Array.from({ length: 6 }).map((_, i) => (
<td key={i} className="px-4 py-3">
<div className="h-4 animate-pulse rounded bg-slate-100" />
</td>
))}
</tr>
);
}
export default function TransactionsPage() {
const { addToast } = useToast();
const { data: categoriesData } = useCategories();
const [month, setMonth] = useState(currentMonth());
const [categoryFilter, setCategoryFilter] = useState("");
const [typeFilter, setTypeFilter] = useState<TransactionType | "">("");
const [page, setPage] = useState(1);
const [formOpen, setFormOpen] = useState(false);
const [editingTx, setEditingTx] = useState<Transaction | undefined>();
const [deleteTarget, setDeleteTarget] = useState<Transaction | undefined>();
const filters = {
month: month || undefined,
category_id: categoryFilter || undefined,
type: typeFilter || undefined,
page,
per_page: PER_PAGE,
};
const { data, isLoading } = useTransactions(filters);
const deleteMutation = useDeleteTransaction();
const totalPages = data ? Math.ceil(data.total / PER_PAGE) : 0;
function openCreate() {
setEditingTx(undefined);
setFormOpen(true);
}
function openEdit(tx: Transaction) {
setEditingTx(tx);
setFormOpen(true);
}
function handleFilterChange() {
setPage(1);
}
function handleDelete() {
if (!deleteTarget) return;
deleteMutation.mutate(deleteTarget.id, {
onSuccess: () => {
addToast({ type: "success", message: "Transaction supprimée" });
setDeleteTarget(undefined);
},
onError: () =>
addToast({ type: "error", message: "Erreur lors de la suppression" }),
});
}
const categories = categoriesData ?? [];
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-2">
<h1 className="text-xl font-semibold text-slate-800">Transactions</h1>
<div className="flex gap-2">
<button
onClick={() => downloadExport(`/api/v1/export/csv?month=${month}`, `transactions_${month}.csv`).catch(() => addToast({ type: "error", message: "Erreur export CSV" }))}
className="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
>
<Download size={14} /> CSV
</button>
<button
onClick={() => downloadExport(`/api/v1/export/pdf?month=${month}`, `transactions_${month}.pdf`).catch(() => addToast({ type: "error", message: "Erreur export PDF" }))}
className="flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm text-slate-700 hover:bg-slate-50"
>
<Download size={14} /> PDF
</button>
<button
onClick={openCreate}
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-white hover:bg-primary/90"
>
<Plus size={16} />
Nouvelle
</button>
</div>
</div>
{/* Filters */}
<div className="mb-4 flex flex-wrap gap-3">
<input
type="month"
value={month}
onChange={(e) => {
setMonth(e.target.value);
handleFilterChange();
}}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30"
/>
<select
value={categoryFilter}
onChange={(e) => {
setCategoryFilter(e.target.value);
handleFilterChange();
}}
className="rounded-lg border border-slate-200 px-3 py-1.5 text-sm outline-none focus:ring-2 focus:ring-primary/30"
>
<option value="">Toutes les catégories</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<div className="flex rounded-lg border border-slate-200">
{(
[
{ value: "", label: "Tout" },
{ value: "income", label: "Revenus" },
{ value: "expense", label: "Dépenses" },
] as { value: TransactionType | ""; label: string }[]
).map(({ value, label }) => (
<button
key={value}
onClick={() => {
setTypeFilter(value);
handleFilterChange();
}}
className={`px-3 py-1.5 text-sm font-medium transition-colors first:rounded-l-lg last:rounded-r-lg ${
typeFilter === value
? "bg-primary text-white"
: "text-slate-600 hover:bg-slate-50"
}`}
>
{label}
</button>
))}
</div>
</div>
{/* Table */}
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-slate-200">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50">
<th className="px-4 py-3 text-left font-medium text-slate-500">
Date
</th>
<th className="px-4 py-3 text-left font-medium text-slate-500">
Description
</th>
<th className="px-4 py-3 text-left font-medium text-slate-500">
Catégorie
</th>
<th className="px-4 py-3 text-right font-medium text-slate-500">
Montant
</th>
<th className="px-4 py-3 text-center font-medium text-slate-500">
Type
</th>
<th className="px-4 py-3 text-right font-medium text-slate-500">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => <SkeletonRow key={i} />)
) : !data || data.items.length === 0 ? (
<tr>
<td
colSpan={6}
className="px-4 py-12 text-center text-slate-400"
>
<p className="font-medium">Aucune transaction</p>
<p className="mt-1 text-xs">
Ajoutez votre première transaction avec le bouton ci-dessus.
</p>
</td>
</tr>
) : (
data.items.map((tx) => (
<tr
key={tx.id}
className="group transition-colors hover:bg-slate-50"
>
<td className="whitespace-nowrap px-4 py-3 text-slate-600">
{formatDate(tx.transaction_date)}
</td>
<td className="px-4 py-3 text-slate-800">
{tx.description ?? (
<span className="text-slate-400 italic"></span>
)}
</td>
<td className="px-4 py-3">
<span
className="inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium"
style={{
backgroundColor: tx.category.color
? `${tx.category.color}20`
: "#e2e8f0",
color: tx.category.color ?? "#64748b",
}}
>
{tx.category.name}
</span>
</td>
<td
className={`whitespace-nowrap px-4 py-3 text-right font-medium tabular-nums ${
tx.type === "income"
? "text-green-600"
: "text-slate-800"
}`}
>
{tx.type === "income" ? "+" : "-"}
{formatCurrency(tx.amount_cents)}
</td>
<td className="px-4 py-3 text-center">
{tx.type === "income" ? (
<TrendingUp
size={16}
className="mx-auto text-green-500"
/>
) : (
<TrendingDown
size={16}
className="mx-auto text-red-400"
/>
)}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<button
onClick={() => openEdit(tx)}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
aria-label="Modifier"
>
<Pencil size={14} />
</button>
<button
onClick={() => setDeleteTarget(tx)}
className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600"
aria-label="Supprimer"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{data && totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-100 px-4 py-3">
<span className="text-xs text-slate-500">
{data.total} transaction{data.total > 1 ? "s" : ""} au total
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
>
<ChevronLeft size={16} />
</button>
{Array.from({ length: totalPages }, (_, i) => i + 1).map(
(p) => (
<button
key={p}
onClick={() => setPage(p)}
className={`min-w-[2rem] rounded px-2 py-1 text-xs font-medium ${
p === page
? "bg-primary text-white"
: "text-slate-500 hover:bg-slate-100"
}`}
>
{p}
</button>
),
)}
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="rounded p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-700 disabled:opacity-30"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
{/* Modals */}
<TransactionForm
isOpen={formOpen}
onClose={() => setFormOpen(false)}
transaction={editingTx}
/>
<DeleteConfirmModal
isOpen={!!deleteTarget}
message={
deleteTarget
? `Supprimer la transaction "${deleteTarget.description ?? formatCurrency(deleteTarget.amount_cents)}" ?`
: ""
}
isLoading={deleteMutation.isPending}
onConfirm={handleDelete}
onClose={() => setDeleteTarget(undefined)}
/>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { setClientTokens, setOnAuthFailed } from "../api/client";
import type { User } from "../api/types";
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
}
interface AuthActions {
login: (user: User, accessToken: string, refreshToken: string) => void;
updateTokens: (accessToken: string) => void;
logout: () => void;
registerOnAuthFailed: (cb: () => void) => void;
}
export const useAuthStore = create<AuthState & AuthActions>()(
persist(
(set, get) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
login(user, accessToken, refreshToken) {
setClientTokens(accessToken, refreshToken);
set({ user, accessToken, refreshToken, isAuthenticated: true });
},
updateTokens(accessToken) {
setClientTokens(accessToken, get().refreshToken);
set({ accessToken });
},
logout() {
setClientTokens(null, null);
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
});
},
registerOnAuthFailed(cb) {
setOnAuthFailed(cb);
},
}),
{
name: "budget-tracker-auth",
onRehydrateStorage: () => (state) => {
if (state?.accessToken) {
setClientTokens(state.accessToken, state.refreshToken);
}
},
},
),
);
+25
View File
@@ -0,0 +1,25 @@
export function centsToEuros(cents: number): number {
return cents / 100;
}
export function eurosToCents(euros: number): number {
return Math.round(euros * 100);
}
export function formatCurrency(cents: number): string {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(cents / 100);
}
export function formatDate(dateStr: string): string {
return new Intl.DateTimeFormat("fr-FR").format(new Date(dateStr + "T00:00:00"));
}
export function currentMonth(): string {
const now = new Date();
const y = now.getFullYear();
const m = String(now.getMonth() + 1).padStart(2, "0");
return `${y}-${m}`;
}
+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/, ""),
},
},
},
});