mirror of
https://github.com/sortedcord/alemno-payments.git
synced 2026-07-22 04:02:49 +05:30
feat: Add API endpoint for managing categories
This commit is contained in:
35
app/api/v1/categories.py
Normal file
35
app/api/v1/categories.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from typing import List
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from app.core.dependencies import get_category_service
|
||||
from app.core.exceptions import CategoryAlreadyExistsException
|
||||
from app.schemas.category import CategoryCreate, CategoryResponse
|
||||
from app.services.category_service import CategoryService
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[CategoryResponse])
|
||||
async def list_categories(
|
||||
service: CategoryService = Depends(get_category_service),
|
||||
):
|
||||
"""
|
||||
Returns the list of all available categories ordered alphabetically.
|
||||
"""
|
||||
return await service.list_categories()
|
||||
|
||||
|
||||
@router.post("", response_model=CategoryResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_category(
|
||||
category_in: CategoryCreate,
|
||||
service: CategoryService = Depends(get_category_service),
|
||||
):
|
||||
"""
|
||||
Creates a new custom category. The name must be unique.
|
||||
"""
|
||||
try:
|
||||
return await service.create_category(name=category_in.name)
|
||||
except CategoryAlreadyExistsException as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=str(e),
|
||||
)
|
||||
@@ -3,6 +3,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.session import get_db
|
||||
from app.repositories.job_repository import JobRepository
|
||||
from app.services.job_service import JobService
|
||||
from app.services.category_service import CategoryService
|
||||
|
||||
|
||||
def get_job_repository(db: AsyncSession = Depends(get_db)) -> JobRepository:
|
||||
@@ -13,3 +14,8 @@ def get_job_repository(db: AsyncSession = Depends(get_db)) -> JobRepository:
|
||||
def get_job_service(repo: JobRepository = Depends(get_job_repository)) -> JobService:
|
||||
"""Dependency to retrieve JobService"""
|
||||
return JobService(repo)
|
||||
|
||||
|
||||
def get_category_service(db: AsyncSession = Depends(get_db)) -> CategoryService:
|
||||
"""Dependency to retrieve CategoryService"""
|
||||
return CategoryService(db)
|
||||
|
||||
@@ -18,3 +18,11 @@ class InvalidCSVException(AlemnoException):
|
||||
def __init__(self, detail: str):
|
||||
self.detail = detail
|
||||
super().__init__(detail)
|
||||
|
||||
|
||||
class CategoryAlreadyExistsException(AlemnoException):
|
||||
"""Raised when a category already exists"""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
super().__init__(f"Category '{name}' already exists.")
|
||||
|
||||
@@ -2,5 +2,6 @@ from app.db.base import Base
|
||||
from app.db.models.job import Job
|
||||
from app.db.models.transaction import Transaction
|
||||
from app.db.models.job_summary import JobSummary
|
||||
from app.db.models.category import Category
|
||||
|
||||
__all__ = ["Base", "Job", "Transaction", "JobSummary"]
|
||||
__all__ = ["Base", "Job", "Transaction", "JobSummary", "Category"]
|
||||
|
||||
10
app/db/models/category.py
Normal file
10
app/db/models/category.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from sqlalchemy import Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class Category(Base):
|
||||
__tablename__ = "categories"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
26
app/main.py
26
app/main.py
@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from app.api.v1.jobs import router as jobs_router
|
||||
from app.api.v1.categories import router as categories_router
|
||||
from app.core.config import settings
|
||||
from app.db.base import Base
|
||||
from app.db.session import engine
|
||||
@@ -12,6 +13,28 @@ async def lifespan(app: FastAPI):
|
||||
# Startup: Create tables if they don't exist
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
|
||||
# Seed default categories if empty
|
||||
from app.db.session import AsyncSessionLocal
|
||||
from app.db.models.category import Category
|
||||
from sqlalchemy import select
|
||||
|
||||
async with AsyncSessionLocal() as session:
|
||||
result = await session.execute(select(Category))
|
||||
if not result.scalars().first():
|
||||
defaults = [
|
||||
Category(name="Food"),
|
||||
Category(name="Shopping"),
|
||||
Category(name="Travel"),
|
||||
Category(name="Transport"),
|
||||
Category(name="Utilities"),
|
||||
Category(name="Cash Withdrawal"),
|
||||
Category(name="Entertainment"),
|
||||
Category(name="Other"),
|
||||
]
|
||||
session.add_all(defaults)
|
||||
await session.commit()
|
||||
|
||||
yield
|
||||
# Shutdown: Clean up pool
|
||||
await engine.dispose()
|
||||
@@ -32,8 +55,9 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Include API Router
|
||||
# Include API Routers
|
||||
app.include_router(jobs_router, prefix=settings.API_V1_STR)
|
||||
app.include_router(categories_router, prefix=settings.API_V1_STR)
|
||||
|
||||
|
||||
# Root healthcheck endpoint
|
||||
|
||||
28
app/repositories/category_repository.py
Normal file
28
app/repositories/category_repository.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from typing import List, Optional
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.db.models.category import Category
|
||||
|
||||
|
||||
class CategoryRepository:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def list_all(self) -> List[Category]:
|
||||
"""List all category records ordered alphabetically by name"""
|
||||
query = select(Category).order_by(Category.name.asc())
|
||||
result = await self.db.execute(query)
|
||||
return list(result.scalars().all())
|
||||
|
||||
async def create(self, name: str) -> Category:
|
||||
"""Create a new category record"""
|
||||
category = Category(name=name)
|
||||
self.db.add(category)
|
||||
await self.db.flush()
|
||||
return category
|
||||
|
||||
async def get_by_name(self, name: str) -> Optional[Category]:
|
||||
"""Fetch a category record by name (case-insensitive)"""
|
||||
query = select(Category).where(Category.name.ilike(name))
|
||||
result = await self.db.execute(query)
|
||||
return result.scalar_one_or_none()
|
||||
17
app/schemas/category.py
Normal file
17
app/schemas/category.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class CategoryBase(BaseModel):
|
||||
name: str = Field(
|
||||
..., description="Unique category name", min_length=1, max_length=100
|
||||
)
|
||||
|
||||
|
||||
class CategoryCreate(CategoryBase):
|
||||
pass
|
||||
|
||||
|
||||
class CategoryResponse(CategoryBase):
|
||||
id: int
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
26
app/services/category_service.py
Normal file
26
app/services/category_service.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from typing import List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.repositories.category_repository import CategoryRepository
|
||||
from app.db.models.category import Category
|
||||
from app.core.exceptions import CategoryAlreadyExistsException
|
||||
|
||||
|
||||
class CategoryService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.repo = CategoryRepository(db)
|
||||
|
||||
async def list_categories(self) -> List[Category]:
|
||||
"""Returns all categories alphabetical list"""
|
||||
return await self.repo.list_all()
|
||||
|
||||
async def create_category(self, name: str) -> Category:
|
||||
"""Creates a new category, validating uniqueness first"""
|
||||
name_clean = name.strip()
|
||||
existing = await self.repo.get_by_name(name_clean)
|
||||
if existing:
|
||||
raise CategoryAlreadyExistsException(name_clean)
|
||||
|
||||
category = await self.repo.create(name_clean)
|
||||
await self.db.commit()
|
||||
return category
|
||||
@@ -83,6 +83,7 @@ def parse_and_validate_csv(content: bytes) -> List[Dict[str, Any]]:
|
||||
# Auto-generate txn_id if missing or empty
|
||||
if not txn_id or not txn_id.strip():
|
||||
import uuid
|
||||
|
||||
txn_id = f"GEN_{uuid.uuid4().hex[:8].upper()}"
|
||||
else:
|
||||
txn_id = txn_id.strip()
|
||||
|
||||
147
app/worker.py
147
app/worker.py
@@ -24,7 +24,7 @@ class TransactionClassification(BaseModel):
|
||||
description="The unique transaction ID (txn_id) from the input."
|
||||
)
|
||||
category: str = Field(
|
||||
description="Assigned category, must be one of: Food, Shopping, Travel, Transport, Utilities, Cash Withdrawal, Entertainment, or Other."
|
||||
description="Assigned category matching one of the allowed categories list."
|
||||
)
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class BatchClassificationResponse(BaseModel):
|
||||
async def classify_transactions_batch(
|
||||
client: genai.Client,
|
||||
batch_txns: List[Dict[str, Any]],
|
||||
allowed_categories: List[str],
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
Calls the Gemini model to classify a batch of transactions.
|
||||
@@ -55,8 +56,7 @@ async def classify_transactions_batch(
|
||||
|
||||
input_prompt = (
|
||||
"Classify the following financial transactions. For each transaction, match its txn_id "
|
||||
"and assign one of the allowed categories: Food, Shopping, Travel, Transport, Utilities, "
|
||||
"Cash Withdrawal, Entertainment, or Other based on the merchant name and details:\n\n"
|
||||
f"and assign one of the allowed categories: {', '.join(allowed_categories)} based on the merchant name and details:\n\n"
|
||||
f"{json.dumps(prompt_data, indent=2)}"
|
||||
)
|
||||
|
||||
@@ -146,6 +146,9 @@ def process_transaction_job(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
|
||||
|
||||
async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
from app.db.session import engine
|
||||
|
||||
await engine.dispose()
|
||||
async with AsyncSessionLocal() as db:
|
||||
repo = JobRepository(db)
|
||||
job = await repo.get_by_id(job_id)
|
||||
@@ -215,6 +218,28 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
llm_results = {}
|
||||
api_key = settings.GEMINI_API_KEY or os.environ.get("GEMINI_API_KEY")
|
||||
if uncategorized_txns:
|
||||
# Load allowed categories from the database categories table
|
||||
from app.db.models.category import Category
|
||||
|
||||
try:
|
||||
res_cats = await db.execute(select(Category.name))
|
||||
allowed_categories = [r[0] for r in res_cats.all()]
|
||||
except Exception as db_err:
|
||||
logger.warning(f"Could not load categories from database: {db_err}")
|
||||
allowed_categories = []
|
||||
|
||||
if not allowed_categories:
|
||||
allowed_categories = [
|
||||
"Food",
|
||||
"Shopping",
|
||||
"Travel",
|
||||
"Transport",
|
||||
"Utilities",
|
||||
"Cash Withdrawal",
|
||||
"Entertainment",
|
||||
"Other",
|
||||
]
|
||||
|
||||
if api_key:
|
||||
logger.info(
|
||||
f"Found {len(uncategorized_txns)} uncategorized transactions. Initializing GenAI client..."
|
||||
@@ -226,7 +251,7 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
for i in range(0, len(uncategorized_txns), batch_size):
|
||||
batch = uncategorized_txns[i : i + batch_size]
|
||||
batch_results = await classify_transactions_batch(
|
||||
client, batch
|
||||
client, batch, allowed_categories
|
||||
)
|
||||
llm_results.update(batch_results)
|
||||
except Exception as e:
|
||||
@@ -247,58 +272,92 @@ async def _process_job_async(job_id: str, transactions: List[Dict[str, Any]]):
|
||||
# Mock categorization mapping based on merchant name keywords
|
||||
for t in uncategorized_txns:
|
||||
merchant_lower = t["merchant"].lower()
|
||||
cat = "Other"
|
||||
if any(
|
||||
kw in merchant_lower
|
||||
for kw in ["swiggy", "zomato", "restaurant", "cafe", "food"]
|
||||
cat = (
|
||||
"Other"
|
||||
if "Other" in allowed_categories
|
||||
else (
|
||||
allowed_categories[0] if allowed_categories else "Other"
|
||||
)
|
||||
)
|
||||
if (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"swiggy",
|
||||
"zomato",
|
||||
"restaurant",
|
||||
"cafe",
|
||||
"food",
|
||||
]
|
||||
)
|
||||
and "Food" in allowed_categories
|
||||
):
|
||||
cat = "Food"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in ["ola", "uber", "taxi", "metro", "train"]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in ["ola", "uber", "taxi", "metro", "train"]
|
||||
)
|
||||
and "Transport" in allowed_categories
|
||||
):
|
||||
cat = "Transport"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"amazon",
|
||||
"flipkart",
|
||||
"shopping",
|
||||
"store",
|
||||
"supermarket",
|
||||
]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"amazon",
|
||||
"flipkart",
|
||||
"shopping",
|
||||
"store",
|
||||
"supermarket",
|
||||
]
|
||||
)
|
||||
and "Shopping" in allowed_categories
|
||||
):
|
||||
cat = "Shopping"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in ["air", "flight", "hotel", "travel", "irctc"]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in ["air", "flight", "hotel", "travel", "irctc"]
|
||||
)
|
||||
and "Travel" in allowed_categories
|
||||
):
|
||||
cat = "Travel"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"electric",
|
||||
"power",
|
||||
"water",
|
||||
"bill",
|
||||
"utility",
|
||||
"utilities",
|
||||
]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"electric",
|
||||
"power",
|
||||
"water",
|
||||
"bill",
|
||||
"utility",
|
||||
"utilities",
|
||||
]
|
||||
)
|
||||
and "Utilities" in allowed_categories
|
||||
):
|
||||
cat = "Utilities"
|
||||
elif any(
|
||||
kw in merchant_lower for kw in ["atm", "cash", "withdrawal"]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in ["atm", "cash", "withdrawal"]
|
||||
)
|
||||
and "Cash Withdrawal" in allowed_categories
|
||||
):
|
||||
cat = "Cash Withdrawal"
|
||||
elif any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"netflix",
|
||||
"spotify",
|
||||
"movie",
|
||||
"show",
|
||||
"entertainment",
|
||||
]
|
||||
elif (
|
||||
any(
|
||||
kw in merchant_lower
|
||||
for kw in [
|
||||
"netflix",
|
||||
"spotify",
|
||||
"movie",
|
||||
"show",
|
||||
"entertainment",
|
||||
]
|
||||
)
|
||||
and "Entertainment" in allowed_categories
|
||||
):
|
||||
cat = "Entertainment"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user