Compare commits
5 Commits
6895609edc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 434de9aa3e | |||
| e3fac99045 | |||
| 9f7378cb69 | |||
| 21339d771d | |||
| d8c2048a9b |
@@ -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
@@ -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/
|
||||||
@@ -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 -->
|
||||||
|
<!--  -->
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
```
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from slowapi import Limiter
|
||||||
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
limiter = Limiter(key_func=get_remote_address)
|
||||||
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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} | {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}"'},
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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}
|
||||||
@@ -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}
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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]
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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 -
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"plugins": ["prettier-plugin-tailwindcss"]
|
||||||
|
}
|
||||||
@@ -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;"]
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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"] }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'inscrire
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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")],
|
||||||
|
};
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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/, ""),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user