diff --git a/budgetter_server/api/v1/api.py b/budgetter_server/api/v1/api.py
new file mode 100644
index 0000000..371043f
--- /dev/null
+++ b/budgetter_server/api/v1/api.py
@@ -0,0 +1,8 @@
+from fastapi import APIRouter
+
+from budgetter_server.api.v1.endpoints import accounts, transactions, import_ofx
+
+api_router = APIRouter()
+api_router.include_router(accounts.router, prefix="/accounts", tags=["accounts"])
+api_router.include_router(transactions.router, prefix="/transactions", tags=["transactions"])
+api_router.include_router(import_ofx.router, prefix="/import", tags=["import"])
diff --git a/budgetter_server/budgetter/__init__.py b/budgetter_server/api/v1/endpoints/__init__.py
similarity index 100%
rename from budgetter_server/budgetter/__init__.py
rename to budgetter_server/api/v1/endpoints/__init__.py
diff --git a/budgetter_server/api/v1/endpoints/accounts.py b/budgetter_server/api/v1/endpoints/accounts.py
new file mode 100644
index 0000000..17b5154
--- /dev/null
+++ b/budgetter_server/api/v1/endpoints/accounts.py
@@ -0,0 +1,140 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlmodel import Session, select
+
+from budgetter_server.db.session import get_session
+from budgetter_server.models import Account, AccountBase
+
+router = APIRouter()
+
+
+@router.post("/", response_model=Account)
+def create_account(
+ *,
+ session: Session = Depends(get_session),
+ account: AccountBase
+) -> Account:
+ """
+ Create a new account.
+
+ Args:
+ session: Database session dependency.
+ account: Account creation data (AccountBase).
+
+ Returns:
+ Account: The created account object.
+ """
+ db_account = Account.model_validate(account)
+ session.add(db_account)
+ session.commit()
+ session.refresh(db_account)
+ return db_account
+
+
+@router.get("/", response_model=list[Account], response_model_exclude={"transactions", "bank"})
+def read_accounts(
+ *,
+ session: Session = Depends(get_session),
+ offset: int = 0,
+ limit: int = 100
+) -> list[Account]:
+ """
+ Retrieve a list of accounts.
+
+ Args:
+ session: Database session dependency.
+ offset: Number of records to skip (pagination).
+ limit: Maximum number of records to return (pagination).
+
+ Returns:
+ list[Account]: List of accounts.
+ """
+ accounts = session.exec(select(Account).offset(offset).limit(limit)).all()
+ return accounts
+
+
+@router.get("/{account_id}", response_model=Account, response_model_exclude={"transactions", "bank"})
+def read_account(
+ *,
+ session: Session = Depends(get_session),
+ account_id: int
+) -> Account:
+ """
+ Retrieve a specific account by ID.
+
+ Args:
+ session: Database session dependency.
+ account_id: ID of the account to retrieve.
+
+ Returns:
+ Account: The requested account.
+
+ Raises:
+ HTTPException: If the account is not found.
+ """
+ account = session.get(Account, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="Account not found")
+ return account
+
+
+@router.put("/{account_id}", response_model=Account, response_model_exclude={"transactions", "bank"})
+def update_account(
+ *,
+ session: Session = Depends(get_session),
+ account_id: int,
+ account_update: AccountBase
+) -> Account:
+ """
+ Update an account.
+
+ Args:
+ session: Database session dependency.
+ account_id: ID of the account to update.
+ account_update: Account update data (AccountBase).
+
+ Returns:
+ Account: The updated account object.
+
+ Raises:
+ HTTPException: If the account is not found.
+ """
+ db_account = session.get(Account, account_id)
+ if not db_account:
+ raise HTTPException(status_code=404, detail="Account not found")
+
+ account_data = account_update.model_dump(exclude_unset=True)
+ for key, value in account_data.items():
+ setattr(db_account, key, value)
+
+ session.add(db_account)
+ session.commit()
+ session.refresh(db_account)
+ return db_account
+
+
+@router.delete("/{account_id}")
+def delete_account(
+ *,
+ session: Session = Depends(get_session),
+ account_id: int
+):
+ """
+ Delete an account.
+
+ Args:
+ session: Database session dependency.
+ account_id: ID of the account to delete.
+
+ Returns:
+ dict: Confirmation message.
+
+ Raises:
+ HTTPException: If the account is not found.
+ """
+ account = session.get(Account, account_id)
+ if not account:
+ raise HTTPException(status_code=404, detail="Account not found")
+
+ session.delete(account)
+ session.commit()
+ return {"ok": True}
diff --git a/budgetter_server/api/v1/endpoints/import_ofx.py b/budgetter_server/api/v1/endpoints/import_ofx.py
new file mode 100644
index 0000000..aad6105
--- /dev/null
+++ b/budgetter_server/api/v1/endpoints/import_ofx.py
@@ -0,0 +1,165 @@
+from typing import Annotated
+
+from fastapi import APIRouter, Depends, UploadFile, File, HTTPException
+from sqlmodel import Session, select
+from ofxtools.Parser import OFXTree
+from ofxtools.models.bank.stmt import STMTRS
+import io
+
+from budgetter_server.db.session import get_session
+from budgetter_server.models import Bank, Account, AccountType, Transaction, Mean, TransactionType
+
+router = APIRouter()
+
+
+@router.post("/ofx", response_model=dict[str, int])
+async def import_ofx(
+ file: Annotated[UploadFile, File()],
+ session: Session = Depends(get_session)
+) -> dict[str, int]:
+ """
+ Import transactions from an OFX file.
+
+ Args:
+ file: The OFX file to upload.
+ session: Database session dependency.
+
+ Returns:
+ dict: Summary including the count of imported transactions.
+ """
+ if not file.filename.endswith(".ofx"):
+ raise HTTPException(status_code=400, detail="Invalid file type. Only .ofx files are supported.")
+
+ content = await file.read()
+
+ # Parse OFX
+ try:
+ parser = OFXTree()
+ parser.parse(io.BytesIO(content))
+ ofx = parser.convert()
+ except Exception as exc:
+ raise HTTPException(status_code=400, detail=f"Failed to parse OFX file: {exc}")
+
+ imported_count = 0
+
+ imported_count = 0
+
+ # Use the statements shortcut provided by ofxtools (handles BANKMSGSRSV1 and CREDITCARDMSGSRSV1)
+ if not hasattr(ofx, "statements"):
+ # Fallback or empty
+ return {"imported_count": 0}
+
+ for stmt in ofx.statements:
+ # Determine account info
+ bank_id_str = "UNKNOWN"
+ acct_id_str = "UNKNOWN"
+ acct_type_enum = AccountType.CHECKING
+
+ if hasattr(stmt, "bankacctfrom"):
+ acct_info = stmt.bankacctfrom
+ bank_id_str = str(acct_info.bankid) if hasattr(acct_info, "bankid") else "UNKNOWN"
+ acct_id_str = str(acct_info.acctid)
+ acct_type_node = acct_info.accttype if hasattr(acct_info, "accttype") else "CHECKING"
+ try:
+ acct_type_enum = AccountType(str(acct_type_node).upper())
+ except ValueError:
+ acct_type_enum = AccountType.CHECKING
+ elif hasattr(stmt, "ccacctfrom"):
+ acct_info = stmt.ccacctfrom
+ bank_id_str = "UNKNOWN_CC"
+ acct_id_str = str(acct_info.acctid)
+ acct_type_enum = AccountType.CREDIT_CARD
+ else:
+ # Skip if no account info
+ continue
+
+ # Find or Create Bank
+ bank = session.exec(select(Bank).where(Bank.swift == bank_id_str)).first()
+ if not bank:
+ bank = Bank(name=f"Bank {bank_id_str}", swift=bank_id_str)
+ session.add(bank)
+ session.commit()
+ session.refresh(bank)
+
+ # Find or Create Account
+ account = session.exec(select(Account).where(Account.account_id == acct_id_str)).first()
+ if not account:
+ # Get balance
+ balance = 0.0
+ if hasattr(stmt, "ledgerbal") and hasattr(stmt.ledgerbal, "balamt"):
+ balance = float(stmt.ledgerbal.balamt)
+
+ account = Account(
+ name=f"Imported {acct_id_str}",
+ account_id=acct_id_str,
+ account_type=acct_type_enum,
+ bank_id=bank.id,
+ amount=balance
+ )
+ session.add(account)
+ session.commit()
+ session.refresh(account)
+
+ # Process Transactions
+ transactions = []
+ if hasattr(stmt, "banktranlist"):
+ btl = stmt.banktranlist
+ try:
+ # Check for stmttrn (list or single)
+ txns = btl.stmttrn
+ if not isinstance(txns, list):
+ txns = [txns]
+ transactions = txns
+ except (AttributeError, KeyError):
+ # Fallback
+ if hasattr(btl, "__iter__"):
+ # Try iterating directly
+ transactions = list(btl)
+
+ for txn in transactions:
+ fitid = str(txn.fitid) if hasattr(txn, "fitid") else None
+ if not fitid and hasattr(txn, "FITID"): fitid = str(txn.FITID)
+
+ if not fitid: continue
+
+ existing = session.exec(select(Transaction).where(Transaction.reference == fitid)).first()
+ if existing:
+ continue
+
+ # Extract fields
+ t_type = txn.trntype if hasattr(txn, "trntype") else "OTHER"
+ amount = txn.trnamt if hasattr(txn, "trnamt") else 0
+
+ # Date handling
+ dt = None
+ if hasattr(txn, "dtposted"):
+ dt = txn.dtposted
+ elif hasattr(txn, "dtuser"):
+ dt = txn.dtuser
+
+ if dt and hasattr(dt, "date"):
+ date_val = dt.date()
+ else:
+ import datetime
+ date_val = datetime.date.today()
+
+ name = str(txn.name) if hasattr(txn, "name") else ""
+ memo = str(txn.memo) if hasattr(txn, "memo") else ""
+ final_name = name if name else memo
+
+ new_txn = Transaction(
+ name=final_name,
+ amount=amount,
+ date=date_val,
+ mean=Mean.CARD,
+ transaction_type=TransactionType.EXPENSES if amount < 0 else TransactionType.INCOME,
+ reference=fitid,
+ account_id=account.id,
+ comment=memo
+ )
+ session.add(new_txn)
+ imported_count += 1
+
+ session.commit()
+
+ return {"imported_count": imported_count}
diff --git a/budgetter_server/api/v1/endpoints/transactions.py b/budgetter_server/api/v1/endpoints/transactions.py
new file mode 100644
index 0000000..70225de
--- /dev/null
+++ b/budgetter_server/api/v1/endpoints/transactions.py
@@ -0,0 +1,140 @@
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlmodel import Session, select
+
+from budgetter_server.db.session import get_session
+from budgetter_server.models import Transaction, TransactionBase
+
+router = APIRouter()
+
+
+@router.post("/", response_model=Transaction)
+def create_transaction(
+ *,
+ session: Session = Depends(get_session),
+ transaction: TransactionBase
+) -> Transaction:
+ """
+ Create a new transaction.
+
+ Args:
+ session: Database session dependency.
+ transaction: Transaction creation data (TransactionBase).
+
+ Returns:
+ Transaction: The created transaction object.
+ """
+ db_transaction = Transaction.model_validate(transaction)
+ session.add(db_transaction)
+ session.commit()
+ session.refresh(db_transaction)
+ return db_transaction
+
+
+@router.get("/", response_model=list[Transaction], response_model_exclude={"account", "category"})
+def read_transactions(
+ *,
+ session: Session = Depends(get_session),
+ offset: int = 0,
+ limit: int = 100
+) -> list[Transaction]:
+ """
+ Retrieve a list of transactions.
+
+ Args:
+ session: Database session dependency.
+ offset: Number of records to skip (pagination).
+ limit: Maximum number of records to return (pagination).
+
+ Returns:
+ list[Transaction]: List of transactions.
+ """
+ transactions = session.exec(select(Transaction).offset(offset).limit(limit)).all()
+ return transactions
+
+
+@router.get("/{transaction_id}", response_model=Transaction, response_model_exclude={"account", "category"})
+def read_transaction(
+ *,
+ session: Session = Depends(get_session),
+ transaction_id: int
+) -> Transaction:
+ """
+ Retrieve a specific transaction by ID.
+
+ Args:
+ session: Database session dependency.
+ transaction_id: ID of the transaction to retrieve.
+
+ Returns:
+ Transaction: The requested transaction.
+
+ Raises:
+ HTTPException: If the transaction is not found.
+ """
+ transaction = session.get(Transaction, transaction_id)
+ if not transaction:
+ raise HTTPException(status_code=404, detail="Transaction not found")
+ return transaction
+
+
+@router.put("/{transaction_id}", response_model=Transaction, response_model_exclude={"account", "category"})
+def update_transaction(
+ *,
+ session: Session = Depends(get_session),
+ transaction_id: int,
+ transaction_update: TransactionBase
+) -> Transaction:
+ """
+ Update a transaction.
+
+ Args:
+ session: Database session dependency.
+ transaction_id: ID of the transaction to update.
+ transaction_update: Transaction update data (TransactionBase).
+
+ Returns:
+ Transaction: The updated transaction object.
+
+ Raises:
+ HTTPException: If the transaction is not found.
+ """
+ db_transaction = session.get(Transaction, transaction_id)
+ if not db_transaction:
+ raise HTTPException(status_code=404, detail="Transaction not found")
+
+ transaction_data = transaction_update.model_dump(exclude_unset=True)
+ for key, value in transaction_data.items():
+ setattr(db_transaction, key, value)
+
+ session.add(db_transaction)
+ session.commit()
+ session.refresh(db_transaction)
+ return db_transaction
+
+
+@router.delete("/{transaction_id}")
+def delete_transaction(
+ *,
+ session: Session = Depends(get_session),
+ transaction_id: int
+):
+ """
+ Delete a transaction.
+
+ Args:
+ session: Database session dependency.
+ transaction_id: ID of the transaction to delete.
+
+ Returns:
+ dict: Confirmation message.
+
+ Raises:
+ HTTPException: If the transaction is not found.
+ """
+ transaction = session.get(Transaction, transaction_id)
+ if not transaction:
+ raise HTTPException(status_code=404, detail="Transaction not found")
+
+ session.delete(transaction)
+ session.commit()
+ return {"ok": True}
diff --git a/budgetter_server/budgetter/asgi.py b/budgetter_server/budgetter/asgi.py
deleted file mode 100644
index c5d7eba..0000000
--- a/budgetter_server/budgetter/asgi.py
+++ /dev/null
@@ -1,15 +0,0 @@
-import os
-
-from django.core.asgi import get_asgi_application
-
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budgetter.settings')
-# Initialize Django ASGI application early to ensure the AppRegistry
-# is populated before importing code that may import ORM models.
-django_asgi_app = get_asgi_application()
-
-from channels.routing import ProtocolTypeRouter
-
-application = ProtocolTypeRouter({
- # Django's ASGI application to handle traditional HTTP requests
- "http": django_asgi_app,
-})
diff --git a/budgetter_server/budgetter/settings.py b/budgetter_server/budgetter/settings.py
deleted file mode 100644
index 39ed1de..0000000
--- a/budgetter_server/budgetter/settings.py
+++ /dev/null
@@ -1,147 +0,0 @@
-"""
-Django settings for server project.
-
-Generated by 'django-admin startproject' using Django 2.2.6.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/2.2/topics/settings/
-
-For the full list of settings and their values, see
-https://docs.djangoproject.com/en/2.2/ref/settings/
-"""
-
-import os
-from pathlib import Path
-
-from budgetter import database
-
-
-# Build paths inside the project like this: os.path.join(BASE_DIR, ...)
-BASE_DIR = Path(__file__).resolve().parent.parent
-
-# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = database.SECRET_KEY
-
-ALLOWED_HOSTS = database.ALLOWED_HOSTS
-
-# SECURITY WARNING: don't run with debug turned on in production!
-DEBUG = True
-
-# Database
-# https://docs.djangoproject.com/en/2.2/ref/settings/#databases
-
-DATABASES = database.DATABASE
-
-# Quick-start development settings - unsuitable for production
-# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/
-
-
-# Application definition
-
-INSTALLED_APPS = [
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'rest_framework',
- 'django_extensions',
- 'drf_yasg',
- 'django_filters',
- # APP
- 'dashboard.apps.DashboardConfig'
-]
-
-MIDDLEWARE = [
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware'
-]
-
-INTERNAL_IPS = ['127.0.0.1']
-
-ROOT_URLCONF = 'budgetter.urls'
-
-TEMPLATES = [
- {
- 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': [os.path.join(BASE_DIR, 'templates')],
- 'APP_DIRS': True,
- 'OPTIONS': {
- 'context_processors': [
- 'django.template.context_processors.debug',
- 'django.template.context_processors.request',
- 'django.contrib.auth.context_processors.auth',
- 'django.contrib.messages.context_processors.messages',
- ],
- },
- },
-]
-
-WSGI_APPLICATION = 'budgetter.wsgi.application'
-
-# Password validation
-# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators
-
-AUTH_PASSWORD_VALIDATORS = [
- {
- 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
- },
-]
-
-
-# Internationalization
-# https://docs.djangoproject.com/en/2.2/topics/i18n/
-
-LANGUAGE_CODE = 'en-us'
-
-TIME_ZONE = 'Europe/Paris'
-
-USE_I18N = True
-
-USE_L10N = True
-
-USE_TZ = True
-
-# Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/2.2/howto/static-files/
-
-STATIC_URL = '/static/'
-STATIC_ROOT = os.path.join(BASE_DIR, 'static')
-# STATICFILES_DIRS = [
-# os.path.join(BASE_DIR, 'static_base')
-# ]
-
-REST_FRAMEWORK = {
- "DATE_INPUT_FORMATS": ["%Y-%m-%d"],
-}
-
-DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
-
-# AI Categorization Settings
-USE_AI_CATEGORIZATION = True # Set to False to use sklearn instead
-AI_CONFIDENCE_THRESHOLD = 0.25 # Minimum confidence (0.0-1.0) to accept prediction
-
-MEDIA_ROOT = os.path.join(BASE_DIR, 'media')
-
-ASGI_APPLICATION = 'budgetter.asgi.application'
-
-CHANNEL_LAYERS = {
- "default": {
- "BACKEND": "channels.layers.InMemoryChannelLayer"
- }
-}
diff --git a/budgetter_server/budgetter/urls.py b/budgetter_server/budgetter/urls.py
deleted file mode 100644
index fb9debe..0000000
--- a/budgetter_server/budgetter/urls.py
+++ /dev/null
@@ -1,45 +0,0 @@
-"""budgetter URL Configuration
-
-The `urlpatterns` list routes URLs to views. For more information please see:
- https://docs.djangoproject.com/en/2.2/topics/http/urls/
-Examples:
-Function views
- 1. Add an import: from my_app import views
- 2. Add a URL to urlpatterns: path('', views.home, name='home')
-Class-based views
- 1. Add an import: from other_app.views import Home
- 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
-Including another URLconf
- 1. Import the include() function: from django.urls import include, path
- 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
-"""
-from django.contrib import admin
-from django.urls import path, include, re_path
-from rest_framework import permissions
-from drf_yasg.views import get_schema_view
-from drf_yasg import openapi
-
-schema_view = get_schema_view(
- openapi.Info(
- title="Snippets API",
- default_version='v1',
- description="Test description",
- terms_of_service="https://www.google.com/policies/terms/",
- contact=openapi.Contact(email="contact@snippets.local"),
- license=openapi.License(name="BSD License"),
- ),
- public=True,
- permission_classes=[permissions.AllowAny],
-)
-
-budget_urls = [
- # Budget API
- path('api/budget/', include('dashboard.urls')),
-]
-
-urlpatterns = [
- path('api/admin/', admin.site.urls),
- re_path(r'^api/swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
-]
-
-urlpatterns.extend(budget_urls)
diff --git a/budgetter_server/budgetter/wsgi.py b/budgetter_server/budgetter/wsgi.py
deleted file mode 100644
index 4f575ac..0000000
--- a/budgetter_server/budgetter/wsgi.py
+++ /dev/null
@@ -1,16 +0,0 @@
-"""
-WSGI config for budgetter project.
-
-It exposes the WSGI callable as a module-level variable named ``application``.
-
-For more information on this file, see
-https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/
-"""
-
-import os
-
-from django.core.wsgi import get_wsgi_application
-
-os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budgetter.settings')
-
-application = get_wsgi_application()
diff --git a/budgetter_server/core/config.py b/budgetter_server/core/config.py
new file mode 100644
index 0000000..82d1656
--- /dev/null
+++ b/budgetter_server/core/config.py
@@ -0,0 +1,26 @@
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+
+class Settings(BaseSettings):
+ """
+ Application settings and configuration.
+
+ Attributes:
+ PROJECT_NAME: Name of the project.
+ API_V1_STR: API version prefix.
+ DATABASE_URL: Database connection string.
+ BACKEND_CORS_ORIGINS: List of allowed CORS origins.
+ """
+ PROJECT_NAME: str = "Budgetter Server"
+ API_V1_STR: str = "/api/v1"
+
+ # Database
+ DATABASE_URL: str
+
+ # CORS
+ BACKEND_CORS_ORIGINS: list[str] = ["*"]
+
+ model_config = SettingsConfigDict(case_sensitive=True, env_file=".env")
+
+
+settings = Settings()
diff --git a/budgetter_server/dashboard/__init__.py b/budgetter_server/dashboard/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/budgetter_server/dashboard/admin.py b/budgetter_server/dashboard/admin.py
deleted file mode 100644
index c96d1f1..0000000
--- a/budgetter_server/dashboard/admin.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from django.contrib import admin
-
-from .models import Bank, Account, Transaction, Category
-
-
-class BankAdmin(admin.ModelAdmin):
- list_display = ('name', 'bic', 'swift')
-
-
-class AccountAdmin(admin.ModelAdmin):
- list_display = ('id', 'name', 'bank', 'amount', 'color', 'last_update', 'status')
- list_filter = ('bank', 'status')
-
-
-class TransactionAdmin(admin.ModelAdmin):
- list_display = ('name', 'amount', 'date', 'account', 'mean')
- list_filter = ('date', 'account')
-
-admin.site.register(Bank, BankAdmin)
-admin.site.register(Account, AccountAdmin)
-admin.site.register(Transaction, TransactionAdmin)
-admin.site.register(Category)
diff --git a/budgetter_server/dashboard/apps.py b/budgetter_server/dashboard/apps.py
deleted file mode 100644
index 9d1c8a2..0000000
--- a/budgetter_server/dashboard/apps.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from django.apps import AppConfig
-
-
-class DashboardConfig(AppConfig):
- name = 'dashboard'
- verbose_name = 'Dashboard'
- def ready(self):
- from django.conf import settings
- if getattr(settings, 'USE_AI_CATEGORIZATION', False):
- from utils.ai_categorizer import preload_model
- import threading
-
- # Run in a separate thread to avoid blocking startup completely,
- # but ensure it starts downloading immediately
- threading.Thread(target=preload_model, daemon=True).start()
diff --git a/budgetter_server/dashboard/fixtures/fr_banks.json b/budgetter_server/dashboard/fixtures/fr_banks.json
deleted file mode 100644
index c5ce1e3..0000000
--- a/budgetter_server/dashboard/fixtures/fr_banks.json
+++ /dev/null
@@ -1,280 +0,0 @@
-[
- {
- "model": "dashboard.bank",
- "pk": 1,
- "fields": {
- "name": "CREDIT AGRICOLE SA",
- "swift": "AGRIFRPP",
- "bic": [
- "10206",
- "11006",
- "11206",
- "11306",
- "11706",
- "12206",
- "12406",
- "12506",
- "12906",
- "13106",
- "13306",
- "13506",
- "13606",
- "13906",
- "14406",
- "14506",
- "14706",
- "14806",
- "16006",
- "16106",
- "16606",
- "16706",
- "16806",
- "16906",
- "17106",
- "17206",
- "17806",
- "17906",
- "18106",
- "18206",
- "18306",
- "18706",
- "19106",
- "19406",
- "19506"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 2,
- "fields": {
- "name": "AXA BANQUE SA",
- "swift": "AXABFRPP",
- "bic": [
- "12548"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 3,
- "fields": {
- "name": "BNP-PARIBAS",
- "swift": "BNPAFRPP",
- "bic": [
- "30004"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 4,
- "fields": {
- "name": "BRED BANQUE POPULAIRE",
- "swift": "BREDFRPP",
- "bic": [
- "10107"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 5,
- "fields": {
- "name": "BANQUE POPULAIRE",
- "swift": "CCBPFRPP",
- "bic": [
- "10907",
- "10807",
- "16807",
- "13507",
- "14607",
- "10207",
- "14707",
- "13807",
- "16607",
- "16707",
- "17807",
- "18707"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 6,
- "fields": {
- "name": "CREDIT COOPERATIF",
- "swift": "CCOPFRPP",
- "bic": [
- "42559"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 7,
- "fields": {
- "name": "CAISSE D'EPARGNE",
- "swift": "CEPAFRPP",
- "bic": [
- "16275",
- "17515",
- "11315",
- "11425",
- "12135",
- "13135",
- "13335",
- "13485",
- "13825",
- "14265",
- "14445",
- "14505",
- "15135",
- "16705",
- "18315",
- "18715"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 8,
- "fields": {
- "name": "CREDIT MUTUEL ARKEA",
- "swift": "CMBRFR2B",
- "bic": [
- "15589",
- "15589"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 9,
- "fields": {
- "name": "CREDIT MUTUEL",
- "swift": "CMCIFR2A",
- "bic": [
- "10278"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 10,
- "fields": {
- "name": "CREDIT MUTUEL-CIC BANQUES",
- "swift": "CMCIFRP1MON",
- "bic": [
- "14690"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 11,
- "fields": {
- "name": "CIC BANQUES",
- "swift": "CMCIFRPP",
- "bic": [
- "10096"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 12,
- "fields": {
- "name": "ARKEA DIRECT BANK",
- "swift": "FTNOFRP1",
- "bic": [
- "14518"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 13,
- "fields": {
- "name": "ORANGE BANK",
- "swift": "GPBAFRPP",
- "bic": [
- "18370"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 14,
- "fields": {
- "name": "PFS CARD SERVICES IRELAND LIMITED",
- "swift": "PRNSFRP1",
- "bic": [
- "21833"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 15,
- "fields": {
- "name": "LA BANQUE POSTALE",
- "swift": "PSSTFRPP",
- "bic": [
- "10011",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041",
- "20041"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 16,
- "fields": {
- "name": "CARREFOUR BANQUE",
- "swift": "SOAPFR22",
- "bic": [
- "19870"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 17,
- "fields": {
- "name": "SOCIETE GENERALE",
- "swift": "SOGEFRPP",
- "bic": [
- "30003"
- ]
- }
- },
- {
- "model": "dashboard.bank",
- "pk": 18,
- "fields": {
- "name": "SOCRAM BANQUE",
- "swift": "SORMFR2N",
- "bic": [
- "12280"
- ]
- }
- }
-]
\ No newline at end of file
diff --git a/budgetter_server/dashboard/models.py b/budgetter_server/dashboard/models.py
deleted file mode 100644
index 1cf8afe..0000000
--- a/budgetter_server/dashboard/models.py
+++ /dev/null
@@ -1,104 +0,0 @@
-import datetime
-
-from django.utils.translation import gettext as _
-from django.db import models
-
-
-class Mean(models.TextChoices):
- CARD = 'CARD'
- CASH = 'CASH'
- TRANSFER = 'TRANSFER'
-
-
-class TransactionType(models.TextChoices):
- EXPENSES = 'EXPENSES'
- INCOME = 'INCOME'
- INTERNAL = 'INTERNAL'
-
-
-class AccountType(models.TextChoices):
- CREDIT_CARD = "CREDIT CARD"
-
-
-class Status(models.TextChoices):
- ACTIVE = 'ACTIVE'
- CLOSED = 'CLOSED'
-
-
-class Bank(models.Model):
- name = models.CharField(max_length=1000, default='')
- swift = models.CharField(max_length=1000, default='')
- bic = models.JSONField(default=list)
-
- def __str__(self):
- return f"{self.name}"
-
-
-class Account(models.Model):
- name = models.CharField(_("Name"), max_length=1000, default='', null=True, blank=True)
- account_id = models.CharField(_("AccountID"), max_length=1000, default='', unique=True)
- account_type = models.CharField(_("AccountType"), choices=AccountType.choices, default=AccountType.CREDIT_CARD)
- bank = models.ForeignKey("Bank", on_delete=models.CASCADE)
- amount = models.FloatField(_("Amount"), default=0)
- color = models.CharField(_("Color"), max_length=1000, default='')
- last_update = models.DateField(_("Date"), default=datetime.date.today)
- status = models.CharField(_("State"), choices=Status.choices, default=Status.ACTIVE)
-
- def __str__(self):
- return f"{self.account_id}"
-
-
-class Category(models.Model):
- name = models.CharField(max_length=1000, default='')
-
- def __str__(self):
- return self.name
-
-
-class CategorizationRule(models.Model):
- keywords = models.CharField(max_length=1000, help_text="Comma-separated keywords or regex")
- category = models.ForeignKey(Category, on_delete=models.CASCADE)
- transaction_type = models.CharField(
- max_length=1000,
- choices=TransactionType.choices,
- null=True,
- blank=True,
- help_text="Optional filter by transaction type"
- )
-
- def matches(self, name: str, memo: str) -> bool:
- """
- Check if rule matches transaction name or memo
- """
- import re
- name = (name or '').lower()
- memo = (memo or '').lower()
- keywords = [kw.strip().lower() for kw in self.keywords.split(',')]
-
- for keyword in keywords:
- # Try exact match
- if keyword in name or keyword in memo:
- return True
- # Try regex
- try:
- if re.search(keyword, name) or re.search(keyword, memo):
- return True
- except re.error:
- continue
- return False
-
- def __str__(self):
- return f"Rule for {self.category.name}: {self.keywords}"
-
-
-class Transaction(models.Model):
- name = models.CharField(max_length=1000, default='')
- amount = models.DecimalField(max_digits=11, decimal_places=2)
- date = models.DateField()
- account = models.ForeignKey("Account", on_delete=models.CASCADE)
- category = models.ForeignKey("Category", on_delete=models.CASCADE, null=True, blank=True)
- comment = models.CharField(max_length=4000, default='', blank=True)
- mean = models.CharField(max_length=1000, choices=Mean.choices, default=Mean.CARD)
- transaction_type = models.CharField(max_length=1000, choices=TransactionType.choices, default=TransactionType.EXPENSES)
- reference = models.CharField(max_length=1000, default='', unique=True)
-
diff --git a/budgetter_server/dashboard/serializers.py b/budgetter_server/dashboard/serializers.py
deleted file mode 100644
index 555d324..0000000
--- a/budgetter_server/dashboard/serializers.py
+++ /dev/null
@@ -1,57 +0,0 @@
-import os.path
-from typing import Union
-
-from rest_framework import serializers
-from rest_framework.fields import SerializerMethodField
-from rest_framework.serializers import ModelSerializer
-
-from budgetter.settings import MEDIA_ROOT
-from .models import Bank, Account, Category, Transaction
-
-
-class BankSerializer(ModelSerializer):
- svg_content = SerializerMethodField(read_only=True)
-
- class Meta:
- model = Bank
- fields = '__all__'
-
- @staticmethod
- def get_svg_content(bank_obj: Bank) -> Union[str, None]:
- """
- Return SVG content from logo
-
- :param bank_obj: bank instance
- :return: SVG content as string
- """
-
- try:
- bank_logo = os.path.join(MEDIA_ROOT, "logo", f"{bank_obj.name.lower().replace(' ', '_')}.svg")
- with open(bank_logo, 'r') as svg_file:
- svg_data = svg_file.read()
- return svg_data
- except FileNotFoundError:
- return None
-
-
-class AccountSerializer(ModelSerializer):
- bank = serializers.StringRelatedField()
-
- class Meta:
- model = Account
- fields = '__all__'
-
-
-class CategorySerializer(ModelSerializer):
- class Meta:
- model = Category
- fields = '__all__'
-
-
-class TransactionSerializer(ModelSerializer):
- date = serializers.DateField(format="%Y-%m-%d", required=False) # ISO 8601 format
- account = serializers.StringRelatedField()
-
- class Meta:
- model = Transaction
- fields = '__all__'
diff --git a/budgetter_server/dashboard/urls.py b/budgetter_server/dashboard/urls.py
deleted file mode 100644
index c4089b7..0000000
--- a/budgetter_server/dashboard/urls.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from rest_framework.routers import DefaultRouter
-
-from . import views
-
-router = DefaultRouter()
-
-router.register('bank', views.BankViewSet)
-router.register('account', views.AccountViewSet)
-router.register('category', views.CategoryViewSet)
-router.register('transaction', views.TransactionViewSet)
-
-urlpatterns = router.urls
diff --git a/budgetter_server/dashboard/views.py b/budgetter_server/dashboard/views.py
deleted file mode 100644
index a6b3ef1..0000000
--- a/budgetter_server/dashboard/views.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from django_filters.rest_framework import DjangoFilterBackend
-from rest_framework import status
-from rest_framework.response import Response
-from rest_framework.viewsets import ModelViewSet
-
-from .models import Bank, Account, Category, Transaction
-from .serializers import BankSerializer, AccountSerializer, CategorySerializer, TransactionSerializer
-
-
-class BankViewSet(ModelViewSet):
- queryset = Bank.objects.all()
- serializer_class = BankSerializer
- filter_backends = [DjangoFilterBackend]
- filterset_fields = ['name']
-
-
-class AccountViewSet(ModelViewSet):
- queryset = Account.objects.all()
- serializer_class = AccountSerializer
- filter_backends = [DjangoFilterBackend]
- filterset_fields = ['name', 'bank']
-
-
-class CategoryViewSet(ModelViewSet):
- queryset = Category.objects.all()
- serializer_class = CategorySerializer
- filter_backends = [DjangoFilterBackend]
- filterset_fields = ['name']
-
-
-class TransactionViewSet(ModelViewSet):
- queryset = Transaction.objects.all()
- serializer_class = TransactionSerializer
- filter_backends = [DjangoFilterBackend]
- filterset_fields = ['name', 'amount', 'date']
-
- def create(self, request, *args, **kwargs):
- """
- Override create to find matching category first and multiple objects
-
- :param request: request
- :param args: args
- :param kwargs: kwargs
- :return: Response
- """
-
- serializer = self.get_serializer(data=request.data, many=isinstance(request.data, list))
- serializer.is_valid(raise_exception=True)
- self.perform_create(serializer)
- headers = self.get_success_headers(serializer.data)
- return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
diff --git a/budgetter_server/db/session.py b/budgetter_server/db/session.py
new file mode 100644
index 0000000..e86a74e
--- /dev/null
+++ b/budgetter_server/db/session.py
@@ -0,0 +1,28 @@
+from typing import Generator
+
+from sqlmodel import Session, create_engine, SQLModel
+
+from budgetter_server.core.config import settings
+
+engine = create_engine(settings.DATABASE_URL)
+
+
+def create_db_and_tables() -> None:
+ """
+ Create the database structure and tables.
+
+ This function uses SQLModel metadata to create tables in the database
+ that do not already exist.
+ """
+ SQLModel.metadata.create_all(engine)
+
+
+def get_session() -> Generator[Session, None, None]:
+ """
+ Dependency to provide a database session.
+
+ Yields:
+ Session: A SQLModel database session.
+ """
+ with Session(engine) as session:
+ yield session
diff --git a/budgetter_server/main.py b/budgetter_server/main.py
new file mode 100644
index 0000000..2dc2298
--- /dev/null
+++ b/budgetter_server/main.py
@@ -0,0 +1,48 @@
+from contextlib import asynccontextmanager
+
+from fastapi import FastAPI
+from starlette.middleware.cors import CORSMiddleware
+
+from budgetter_server.api.v1.api import api_router
+from budgetter_server.core.config import settings
+from budgetter_server.db.session import create_db_and_tables
+from budgetter_server.models import Bank, Account, Category, Transaction
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ """
+ Lifespan context manager for the FastAPI application.
+
+ Args:
+ app: The FastAPI application instance.
+ """
+ create_db_and_tables()
+ yield
+
+app = FastAPI(
+ title=settings.PROJECT_NAME,
+ openapi_url=f"{settings.API_V1_STR}/openapi.json",
+ lifespan=lifespan,
+)
+
+# Set all CORS enabled origins
+if settings.BACKEND_CORS_ORIGINS:
+ app.add_middleware(
+ CORSMiddleware,
+ allow_origins=[str(origin) for origin in settings.BACKEND_CORS_ORIGINS],
+ allow_credentials=True,
+ allow_methods=["*"],
+ allow_headers=["*"],
+ )
+
+app.include_router(api_router, prefix=settings.API_V1_STR)
+
+@app.get("/")
+def read_root() -> dict[str, str]:
+ """
+ Root endpoint for health check.
+
+ Returns:
+ dict: detailed welcome message.
+ """
+ return {"message": "Welcome to Budgetter Server API"}
diff --git a/budgetter_server/manage.py b/budgetter_server/manage.py
deleted file mode 100644
index 1c27db2..0000000
--- a/budgetter_server/manage.py
+++ /dev/null
@@ -1,21 +0,0 @@
-#!/usr/bin/env python
-"""Django's command-line utility for administrative tasks."""
-import os
-import sys
-
-
-def main():
- os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'budgetter.settings')
- try:
- from django.core.management import execute_from_command_line
- except ImportError as exc:
- raise ImportError(
- "Couldn't import Django. Are you sure it's installed and "
- "available on your PYTHONPATH environment variable? Did you "
- "forget to activate a virtual environment?"
- ) from exc
- execute_from_command_line(sys.argv)
-
-
-if __name__ == '__main__':
- main()
diff --git a/budgetter_server/media/logo/credit_agricole_sa.svg b/budgetter_server/media/logo/credit_agricole_sa.svg
deleted file mode 100644
index fbac372..0000000
--- a/budgetter_server/media/logo/credit_agricole_sa.svg
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
diff --git a/budgetter_server/models/__init__.py b/budgetter_server/models/__init__.py
new file mode 100644
index 0000000..9a284f5
--- /dev/null
+++ b/budgetter_server/models/__init__.py
@@ -0,0 +1,15 @@
+from .sql_models import (
+ Bank, BankBase,
+ Account, AccountBase, AccountType, Status,
+ Category, CategoryBase,
+ CategorizationRule, CategorizationRuleBase,
+ Transaction, TransactionBase, Mean, TransactionType
+)
+
+__all__ = [
+ "Bank", "BankBase",
+ "Account", "AccountBase", "AccountType", "Status",
+ "Category", "CategoryBase",
+ "CategorizationRule", "CategorizationRuleBase",
+ "Transaction", "TransactionBase", "Mean", "TransactionType"
+]
diff --git a/budgetter_server/models/sql_models.py b/budgetter_server/models/sql_models.py
new file mode 100644
index 0000000..fdf4c23
--- /dev/null
+++ b/budgetter_server/models/sql_models.py
@@ -0,0 +1,132 @@
+from enum import StrEnum
+import datetime
+from decimal import Decimal
+from typing import Optional, List
+
+from sqlalchemy import JSON
+from sqlmodel import Field, Relationship, SQLModel, Column
+
+
+# Enums
+class Mean(StrEnum):
+ """Enumeration for transaction means."""
+ CARD = 'CARD'
+ CASH = 'CASH'
+ TRANSFER = 'TRANSFER'
+
+
+class TransactionType(StrEnum):
+ """Enumeration for transaction types."""
+ EXPENSES = 'EXPENSES'
+ INCOME = 'INCOME'
+ INTERNAL = 'INTERNAL'
+
+
+class AccountType(StrEnum):
+ """Enumeration for account types."""
+ CREDIT_CARD = "CREDIT CARD"
+ CHECKING = "CHECKING"
+ SAVINGS = "SAVINGS"
+
+
+class Status(StrEnum):
+ """Enumeration for account status."""
+ ACTIVE = 'ACTIVE'
+ CLOSED = 'CLOSED'
+
+
+class BankBase(SQLModel):
+ name: str = Field(default='', max_length=1000, description="Name of the bank")
+ swift: str = Field(default='', max_length=1000, description="SWIFT code")
+ bic: List[str] = Field(default_factory=list, sa_column=Column(JSON), description="List of BIC codes")
+
+
+class Bank(BankBase, table=True):
+ """Represents a banking institution."""
+ id: Optional[int] = Field(default=None, primary_key=True)
+ accounts: List["Account"] = Relationship(back_populates="bank")
+
+
+class AccountBase(SQLModel):
+ name: Optional[str] = Field(default='', max_length=1000, description="Account name")
+ account_id: str = Field(default='', max_length=1000, unique=True, index=True, description="Unique account identifier")
+ account_type: AccountType = Field(default=AccountType.CREDIT_CARD, description="Type of account")
+ amount: float = Field(default=0.0, description="Current balance")
+ color: str = Field(default='', max_length=1000, description="Display color")
+ last_update: datetime.date = Field(default_factory=datetime.date.today, description="Last updated date")
+ status: Status = Field(default=Status.ACTIVE, description="Account status")
+ bank_id: Optional[int] = Field(default=None, foreign_key="bank.id", description="ID of the associated bank")
+
+
+class Account(AccountBase, table=True):
+ """Represents a financial account."""
+ id: Optional[int] = Field(default=None, primary_key=True)
+
+ bank: Optional[Bank] = Relationship(back_populates="accounts")
+ transactions: List["Transaction"] = Relationship(back_populates="account")
+
+
+class CategoryBase(SQLModel):
+ name: str = Field(default='', max_length=1000, description="Category name")
+
+
+class Category(CategoryBase, table=True):
+ """Represents a transaction category."""
+ id: Optional[int] = Field(default=None, primary_key=True)
+
+ categorization_rules: List["CategorizationRule"] = Relationship(back_populates="category")
+ transactions: List["Transaction"] = Relationship(back_populates="category")
+
+
+class CategorizationRuleBase(SQLModel):
+ keywords: str = Field(max_length=1000, description="Comma-separated keywords or regex")
+ category_id: Optional[int] = Field(default=None, foreign_key="category.id", description="Target category ID")
+ transaction_type: Optional[TransactionType] = Field(
+ default=None,
+ description="Optional filter by transaction type"
+ )
+
+ def matches(self, name: str, memo: str) -> bool:
+ """
+ Check if the rule matches a transaction based on name or memo.
+ """
+ import re
+ name_str = (name or '').lower()
+ memo_str = (memo or '').lower()
+ keyword_list = [kw.strip().lower() for kw in self.keywords.split(',')]
+
+ for keyword in keyword_list:
+ if keyword in name_str or keyword in memo_str:
+ return True
+ try:
+ if re.search(keyword, name_str) or re.search(keyword, memo_str):
+ return True
+ except re.error:
+ continue
+ return False
+
+
+class CategorizationRule(CategorizationRuleBase, table=True):
+ """Rules for automatically categorizing transactions."""
+ id: Optional[int] = Field(default=None, primary_key=True)
+ category: Optional[Category] = Relationship(back_populates="categorization_rules")
+
+
+class TransactionBase(SQLModel):
+ name: str = Field(default='', max_length=1000, description="Transaction name/payee")
+ amount: Decimal = Field(max_digits=11, decimal_places=2, description="Transaction amount")
+ date: datetime.date = Field(description="Date of transaction")
+ comment: str = Field(default='', max_length=4000, description="User comment")
+ mean: Mean = Field(default=Mean.CARD, description="Payment mean")
+ transaction_type: TransactionType = Field(default=TransactionType.EXPENSES, description="Type of transaction")
+ reference: str = Field(default='', max_length=1000, unique=True, index=True, description="Unique reference ID")
+ account_id: Optional[int] = Field(default=None, foreign_key="account.id", description="Account ID")
+ category_id: Optional[int] = Field(default=None, foreign_key="category.id", description="Category ID")
+
+
+class Transaction(TransactionBase, table=True):
+ """Represents a financial transaction."""
+ id: Optional[int] = Field(default=None, primary_key=True)
+
+ account: Optional[Account] = Relationship(back_populates="transactions")
+ category: Optional[Category] = Relationship(back_populates="transactions")
diff --git a/budgetter_server/utils/__init__.py b/budgetter_server/utils/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/budgetter_server/utils/ai_categorizer.py b/budgetter_server/utils/ai_categorizer.py
deleted file mode 100644
index 8deb892..0000000
--- a/budgetter_server/utils/ai_categorizer.py
+++ /dev/null
@@ -1,108 +0,0 @@
-from typing import Optional
-from transformers import pipeline
-from dashboard.models import Category
-import logging
-
-logger = logging.getLogger(__name__)
-
-_GLOBAL_CLASSIFIER = None
-
-class AICategorizer:
- """
- Transaction categorizer using AI (DistilBART) for zero-shot classification
- """
-
- def __init__(self, categories: list, confidence_threshold: float = 0.5):
- """
- Initialize AI categorizer
-
- Args:
- categories: List of Category objects to classify into
- confidence_threshold: Minimum confidence score to accept prediction
- """
- self.categories = categories
- self.category_names = [cat.name for cat in categories]
- self.category_map = {cat.name: cat for cat in categories}
- self.confidence_threshold = confidence_threshold
-
- # Lazy load the model
- self._initialize_model()
-
- def _initialize_model(self):
- """Initialize the AI model (cached after first call)"""
- global _GLOBAL_CLASSIFIER
- if _GLOBAL_CLASSIFIER is None:
- try:
- logger.info("Loading AI model...")
- # Using zero-shot classification pipeline
- _GLOBAL_CLASSIFIER = pipeline(
- "zero-shot-classification",
- model="valhalla/distilbart-mnli-12-1"
- )
- logger.info("AI model loaded successfully")
- except Exception as e:
- logger.error(f"Failed to load AI model: {e}")
- _GLOBAL_CLASSIFIER = None
-
- self.classifier = _GLOBAL_CLASSIFIER
-
- def predict(self, name: str, memo: str) -> Optional[Category]:
- """
- Predict category for a transaction using AI
-
- Args:
- name: Transaction name/description
- memo: Transaction memo/note
-
- Returns:
- Category object if prediction confidence is above threshold, else None
- """
- if not self.classifier or not self.category_names:
- return None
-
- # Combine name and memo for richer context
- text = f"{name} {memo or ''}".strip()
-
- if not text:
- return None
-
- try:
- # Perform zero-shot classification
- result = self.classifier(
- text,
- candidate_labels=self.category_names,
- multi_label=False
- )
-
- # Get top prediction
- top_label = result['labels'][0]
- top_score = result['scores'][0]
-
- logger.debug(f"AI prediction: '{text}' -> {top_label} ({top_score:.2f})")
-
- # Return category if confidence is above threshold
- if top_score >= self.confidence_threshold:
- return self.category_map.get(top_label)
-
- return None
-
- except Exception as e:
- logger.error(f"Error during AI prediction: {e}")
- return None
-
-def preload_model():
- """
- Preload the AI model into cache and memory.
- This ensures the model is downloaded and ready for use.
- """
- global _GLOBAL_CLASSIFIER
- try:
- if _GLOBAL_CLASSIFIER is None:
- logger.info("Preloading AI model...")
- _GLOBAL_CLASSIFIER = pipeline(
- "zero-shot-classification",
- model="valhalla/distilbart-mnli-12-1"
- )
- logger.info("AI model preloaded successfully")
- except Exception as e:
- logger.error(f"Failed to preload AI model: {e}")
diff --git a/budgetter_server/utils/categorizer.py b/budgetter_server/utils/categorizer.py
deleted file mode 100644
index 6691de7..0000000
--- a/budgetter_server/utils/categorizer.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import re
-from dashboard.models import CategorizationRule, TransactionType
-from typing import Optional
-from dashboard.models import Category
-
-def categorize_transaction(transaction_data: dict, ai_categorizer=None) -> Optional[Category]:
- """
- Categorizes a transaction based on defined rules or AI.
- Priority: Rules → AI
-
- Args:
- transaction_data: Dictionary containing transaction data.
- ai_categorizer: Optional AI categorizer instance.
- Returns:
- Category object or None.
- """
- name = (transaction_data.get('name') or '').lower()
- memo = (transaction_data.get('memo') or '').lower()
-
- # Check rules first (highest priority)
- rules = CategorizationRule.objects.all()
- for rule in rules:
- if rule.matches(name, memo):
- # If transaction type is specified in rule, verify it matches
- if rule.transaction_type:
- txn_type = transaction_data.get('transaction_type')
- if txn_type and txn_type != rule.transaction_type:
- continue
- return rule.category
-
- # If no rule matched, try AI if provided (second priority)
- if ai_categorizer:
- try:
- predicted_category = ai_categorizer.predict(
- transaction_data.get('name', ''),
- transaction_data.get('memo', '')
- )
- if predicted_category:
- return predicted_category
- except Exception:
- pass
-
- return None
diff --git a/budgetter_server/utils/ofx_handler.py b/budgetter_server/utils/ofx_handler.py
deleted file mode 100644
index cf6b05a..0000000
--- a/budgetter_server/utils/ofx_handler.py
+++ /dev/null
@@ -1,47 +0,0 @@
-from ofxtools.Parser import OFXTree
-from decimal import Decimal
-import datetime
-
-def parse_ofx(file_obj) -> list[dict]:
- """
- Parses an OFX file and returns a list of transactions.
- """
- parser = OFXTree()
- parser.parse(file_obj)
- ofx = parser.convert()
-
- parsed_data = []
-
- if not ofx.statements:
- return parsed_data
-
- for stmt in ofx.statements:
- account_data = {
- 'account_id': getattr(stmt.account, 'acctid', None),
- 'bank_id': getattr(stmt.account, 'bankid', None),
- 'account_type': getattr(stmt.account, 'accttype', None),
- 'currency': stmt.curdef,
- 'balance': getattr(stmt.ledgerbal, 'balamt', None),
- 'balance_date': getattr(stmt.ledgerbal, 'dtasof', None),
- 'transactions': []
- }
-
- # Retrieve transactions
- txns = stmt.transactions
-
- for txn in txns:
- # Extract relevant fields
- transaction_data = {
- 'amount': txn.trnamt,
- 'date': txn.dtposted.date(),
- 'name': txn.name or txn.memo or "Unknown",
- 'reference': txn.fitid,
- 'transaction_type': txn.trntype,
- 'memo': txn.memo,
- 'account_id': account_data['account_id']
- }
- account_data['transactions'].append(transaction_data)
-
- parsed_data.append(account_data)
-
- return parsed_data
diff --git a/pyproject.toml b/pyproject.toml
index 9eb2649..2fc9e65 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,21 +10,23 @@ description = "Finance manager server"
readme = "README.md"
requires-python = ">=3.13"
keywords = ["finance", "budget", "management"]
-license = { text = "GPL-3.0" }
+license = "GPL-3.0-only"
classifiers = []
dependencies = [
- "Django>=5.2.8",
- "djangorestframework>=3.16.1",
- "django-filter>=25.2",
- "django-extensions",
- "drf_yasg>=1.21.11",
"psycopg[binary]>=3.2.13",
- "pytz>=2025.2",
"ofxtools>=0.9.5",
"transformers>=4.30.0",
"torch>=2.0.0",
+ "fastapi>=0.100.0",
+ "uvicorn>=0.20.0",
+ "sqlmodel>=0.0.14",
+ "pydantic-settings>=2.0.0",
+ "python-multipart>=0.0.6",
]
+[project.optional-dependencies]
+test = ["pytest>=7.12.0", "httpx>=0.28.1", "pytest-cov>=7.0.0"]
+
[project.urls]
repository = "https://github.com/opierre/Budgetter-server"
@@ -45,3 +47,14 @@ exclude = '''
^skeletons/
)
'''
+
+[tool.setuptools]
+py-modules = []
+
+
+[tool.pytest.ini_options]
+minversion = "7.0"
+addopts = "-ra -q --cov=budgetter_server --cov-report=term-missing --cov-report html:cov_html"
+testpaths = ["tests"]
+python_files = ["test_*.py"]
+pythonpath = [".", "budgetter_server"]
diff --git a/tests/api/v1/test_accounts.py b/tests/api/v1/test_accounts.py
new file mode 100644
index 0000000..1b07843
--- /dev/null
+++ b/tests/api/v1/test_accounts.py
@@ -0,0 +1,65 @@
+from fastapi.testclient import TestClient
+from sqlmodel import Session
+
+from budgetter_server.models import AccountType, Status, AccountBase
+
+
+class TestAccounts:
+ """
+ Test suite for accounts API endpoints.
+ """
+
+ def test_create_account(self, client: TestClient) -> None:
+ """
+ Test creating a new account via API.
+
+ Verifies:
+ - Response status code is 200.
+ - Response JSON contains expected fields.
+ """
+ response = client.post(
+ "/api/v1/accounts/",
+ json={
+ "name": "Test Savings",
+ "account_id": "SAV001",
+ "account_type": AccountType.CREDIT_CARD,
+ "amount": 1000.50,
+ "color": "blue",
+ "last_update": "2023-10-01",
+ "status": Status.ACTIVE
+ }
+ )
+ assert response.status_code == 200
+ data = response.json()
+ assert data["name"] == "Test Savings"
+ assert data["account_id"] == "SAV001"
+ assert data["amount"] == 1000.50
+ assert "id" in data
+
+ def test_read_accounts(self, client: TestClient) -> None:
+ """
+ Test reading accounts list.
+
+ Verifies:
+ - Response status code is 200.
+ - Returns a list of accounts.
+ """
+ # Create an account first
+ client.post(
+ "/api/v1/accounts/",
+ json={
+ "name": "Test Checking",
+ "account_id": "CHK001",
+ "account_type": AccountType.CREDIT_CARD,
+ "amount": 500.0,
+ "color": "green",
+ "last_update": "2023-10-01",
+ "status": Status.ACTIVE
+ }
+ )
+
+ response = client.get("/api/v1/accounts/")
+ assert response.status_code == 200
+ data = response.json()
+ assert len(data) >= 1
+ assert data[0]["name"] == "Test Checking"
diff --git a/tests/api/v1/test_import.py b/tests/api/v1/test_import.py
new file mode 100644
index 0000000..ecf38d0
--- /dev/null
+++ b/tests/api/v1/test_import.py
@@ -0,0 +1,45 @@
+from pathlib import Path
+from fastapi.testclient import TestClient
+from sqlmodel import Session, select
+from budgetter_server.models import Transaction, Bank, Account
+
+class TestImport:
+ """
+ Test suite for OFX import.
+ """
+
+ def test_import_ofx(self, client: TestClient, session: Session) -> None:
+ """
+ Test importing an OFX file.
+
+ Verifies:
+ - Response 200 and count.
+ - Bank, Account, and Transactions are created in DB.
+ """
+ file_path = Path(__file__).parent.parent.parent.joinpath("fixtures", "sample.ofx")
+ with open(file_path, "rb") as f:
+ response = client.post(
+ "/api/v1/import/ofx",
+ files={"file": ("sample.ofx", f, "application/x-ofx")}
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["imported_count"] == 2
+
+ # Verify DB
+ bank = session.exec(select(Bank).where(Bank.swift == "TESTBANK")).first()
+ assert bank is not None
+ assert bank.name == "Bank TESTBANK"
+
+ account = session.exec(select(Account).where(Account.account_id == "123456789")).first()
+ assert account is not None
+ assert account.amount == 5000.00
+ assert account.bank_id == bank.id
+
+ txns = session.exec(select(Transaction).where(Transaction.account_id == account.id)).all()
+ assert len(txns) == 2
+
+ debit = next(t for t in txns if t.reference == "REF101")
+ assert debit.amount == -50.00
+ assert debit.name == "Grocery Store"
diff --git a/tests/api/v1/test_transactions.py b/tests/api/v1/test_transactions.py
new file mode 100644
index 0000000..67fc654
--- /dev/null
+++ b/tests/api/v1/test_transactions.py
@@ -0,0 +1,67 @@
+from fastapi.testclient import TestClient
+from sqlmodel import Session, select
+
+from budgetter_server.models import Transaction, Mean, TransactionType, AccountType, Status
+
+
+class TestTransactions:
+ """
+ Test suite for transactions API endpoints.
+ """
+
+ def test_create_transaction(self, client: TestClient, session: Session) -> None:
+ """
+ Test creating a new transaction.
+
+ Verifies:
+ - Response status code is 200.
+ - Database actually contains the new transaction.
+ """
+ # Create an account first (dependency)
+ client.post(
+ "/api/v1/accounts/",
+ json={
+ "name": "Test Account",
+ "account_id": "ACC123",
+ "account_type": AccountType.CREDIT_CARD,
+ "status": Status.ACTIVE,
+ "amount": 100.0,
+ "color": "red",
+ "last_update": "2023-01-01"
+ }
+ )
+
+ # Create transaction
+ response = client.post(
+ "/api/v1/transactions/",
+ json={
+ "name": "Groceries",
+ "amount": 50.25,
+ "date": "2023-11-15",
+ "comment": "Weekly shopping",
+ "mean": Mean.CARD,
+ "transaction_type": TransactionType.EXPENSES,
+ "reference": "REF123456",
+ "account_id": 1
+ }
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["name"] == "Groceries"
+ assert data["amount"] == "50.25"
+
+ # Verify DB update directly via session
+ statement = select(Transaction).where(Transaction.reference == "REF123456")
+ transaction_in_db = session.exec(statement).first()
+ assert transaction_in_db is not None
+ assert transaction_in_db.name == "Groceries"
+ assert transaction_in_db.amount == 50.25
+
+ def test_read_transactions(self, client: TestClient) -> None:
+ """
+ Test reading transactions list.
+ """
+ response = client.get("/api/v1/transactions/")
+ assert response.status_code == 200
+ assert isinstance(response.json(), list)
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..4e108f5
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,60 @@
+import os
+import pytest
+from typing import Generator
+from fastapi.testclient import TestClient
+from sqlmodel import Session, SQLModel, create_engine
+from sqlmodel.pool import StaticPool
+
+# Set env var BEFORE importing app modules to satisfy Settings validation
+os.environ["DATABASE_URL"] = "sqlite:///:memory:"
+
+from budgetter_server.main import app
+from budgetter_server.db.session import get_session
+
+
+@pytest.fixture(name="session")
+def session_fixture() -> Generator[Session, None, None]:
+ """
+ Creates a new database session for each test.
+
+ This fixture:
+ 1. Creates an in-memory SQLite engine.
+ 2. Creates all tables defined in SQLModel metadata.
+ 3. Yields a session connected to this engine.
+ 4. Drops all tables after the test.
+ """
+ # Use SQLite in-memory database for tests
+ engine = create_engine(
+ "sqlite:///:memory:", # In-memory DB
+ connect_args={"check_same_thread": False},
+ poolclass=StaticPool,
+ )
+
+ SQLModel.metadata.create_all(engine)
+
+ with Session(engine) as session:
+ yield session
+
+ SQLModel.metadata.drop_all(engine)
+ engine.dispose()
+
+
+@pytest.fixture(name="client")
+def client_fixture(session: Session) -> Generator[TestClient, None, None]:
+ """
+ Creates a TestClient with overridden database dependency.
+
+ This ensures that the API uses the test database session
+ instead of the production one.
+ """
+ def get_session_override():
+ return session
+
+ # Override the dependency
+ app.dependency_overrides[get_session] = get_session_override
+
+ client = TestClient(app)
+ yield client
+
+ # Clean up overrides
+ app.dependency_overrides.clear()
diff --git a/tests/fixtures/sample.ofx b/tests/fixtures/sample.ofx
new file mode 100644
index 0000000..9c6e220
--- /dev/null
+++ b/tests/fixtures/sample.ofx
@@ -0,0 +1,63 @@
+OFXHEADER:100
+DATA:OFXSGML
+VERSION:102
+SECURITY:NONE
+ENCODING:USASCII
+CHARSET:1252
+COMPRESSION:NONE
+OLDFILEUID:NONE
+NEWFILEUID:NONE
+
+
+
+
+
+0
+INFO
+
+20231115120000
+ENG
+
+
+
+
+1
+
+0
+INFO
+
+
+USD
+
+TESTBANK
+123456789
+CHECKING
+
+
+20231101120000
+20231115120000
+
+DEBIT
+20231110120000
+-50.00
+REF101
+Grocery Store
+Weekly Food
+
+
+CREDIT
+20231112120000
+1200.00
+REF102
+Salary
+November Pay
+
+
+
+5000.00
+20231115120000
+
+
+
+
+