Upload 24 files
Browse files- app/__init__.py +0 -0
- app/__pycache__/__init__.cpython-311.pyc +0 -0
- app/__pycache__/main.cpython-311.pyc +0 -0
- app/config/__init__.py +0 -0
- app/config/__pycache__/__init__.cpython-311.pyc +0 -0
- app/config/__pycache__/settings.cpython-311.pyc +0 -0
- app/config/settings.py +11 -0
- app/database/__init__.py +0 -0
- app/database/__pycache__/__init__.cpython-311.pyc +0 -0
- app/database/__pycache__/connection.cpython-311.pyc +0 -0
- app/database/connection.py +27 -0
- app/main.py +29 -0
- app/models/__init__.py +0 -0
- app/models/__pycache__/__init__.cpython-311.pyc +0 -0
- app/models/__pycache__/monthly_record.cpython-311.pyc +0 -0
- app/models/monthly_record.py +56 -0
- app/routes/__init__.py +0 -0
- app/routes/__pycache__/__init__.cpython-311.pyc +0 -0
- app/routes/__pycache__/records.cpython-311.pyc +0 -0
- app/routes/records.py +50 -0
- app/services/__init__.py +0 -0
- app/services/__pycache__/__init__.cpython-311.pyc +0 -0
- app/services/__pycache__/record_service.cpython-311.pyc +0 -0
- app/services/record_service.py +99 -0
app/__init__.py
ADDED
|
File without changes
|
app/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (178 Bytes). View file
|
|
|
app/__pycache__/main.cpython-311.pyc
ADDED
|
Binary file (1.75 kB). View file
|
|
|
app/config/__init__.py
ADDED
|
File without changes
|
app/config/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (185 Bytes). View file
|
|
|
app/config/__pycache__/settings.cpython-311.pyc
ADDED
|
Binary file (1.02 kB). View file
|
|
|
app/config/settings.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
MONGODB_URL = os.getenv("MONGODB_URL", "mongodb://localhost:27017")
|
| 7 |
+
DATABASE_NAME = os.getenv("DATABASE_NAME", "expense_tracker")
|
| 8 |
+
COLLECTION_NAME = os.getenv("COLLECTION_NAME", "monthly_records")
|
| 9 |
+
|
| 10 |
+
# CORS
|
| 11 |
+
ALLOWED_ORIGINS = [o.strip() for o in os.getenv("ALLOWED_ORIGINS", "http://localhost:3000").split(",") if o.strip()]
|
app/database/__init__.py
ADDED
|
File without changes
|
app/database/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (187 Bytes). View file
|
|
|
app/database/__pycache__/connection.cpython-311.pyc
ADDED
|
Binary file (1.91 kB). View file
|
|
|
app/database/connection.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorDatabase, AsyncIOMotorCollection
|
| 3 |
+
from . import __init__ # noqa: F401
|
| 4 |
+
from ..config.settings import MONGODB_URL, DATABASE_NAME, COLLECTION_NAME
|
| 5 |
+
|
| 6 |
+
_client: Optional[AsyncIOMotorClient] = None
|
| 7 |
+
_db: Optional[AsyncIOMotorDatabase] = None
|
| 8 |
+
_collection: Optional[AsyncIOMotorCollection] = None
|
| 9 |
+
|
| 10 |
+
async def connect_to_mongo() -> None:
|
| 11 |
+
global _client, _db, _collection
|
| 12 |
+
_client = AsyncIOMotorClient(MONGODB_URL)
|
| 13 |
+
_db = _client[DATABASE_NAME]
|
| 14 |
+
_collection = _db[COLLECTION_NAME]
|
| 15 |
+
|
| 16 |
+
async def close_mongo_connection() -> None:
|
| 17 |
+
global _client
|
| 18 |
+
if _client:
|
| 19 |
+
_client.close()
|
| 20 |
+
|
| 21 |
+
def get_db() -> AsyncIOMotorDatabase:
|
| 22 |
+
assert _db is not None, "DB not initialized. Call connect_to_mongo() first."
|
| 23 |
+
return _db
|
| 24 |
+
|
| 25 |
+
def get_collection() -> AsyncIOMotorCollection:
|
| 26 |
+
assert _collection is not None, "Collection not initialized. Call connect_to_mongo() first."
|
| 27 |
+
return _collection
|
app/main.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
|
| 4 |
+
from .routes.records import router as records_router
|
| 5 |
+
from .database.connection import connect_to_mongo, close_mongo_connection
|
| 6 |
+
from .services.record_service import RecordService
|
| 7 |
+
from .config.settings import ALLOWED_ORIGINS
|
| 8 |
+
|
| 9 |
+
app = FastAPI(title="Expense Tracker API", version="1.0.0")
|
| 10 |
+
|
| 11 |
+
# CORS
|
| 12 |
+
app.add_middleware(
|
| 13 |
+
CORSMiddleware,
|
| 14 |
+
allow_origins=ALLOWED_ORIGINS,
|
| 15 |
+
allow_credentials=True,
|
| 16 |
+
allow_methods=["*"],
|
| 17 |
+
allow_headers=["*"],
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
@app.on_event("startup")
|
| 21 |
+
async def startup_event():
|
| 22 |
+
await connect_to_mongo()
|
| 23 |
+
await RecordService().ensure_indexes()
|
| 24 |
+
|
| 25 |
+
@app.on_event("shutdown")
|
| 26 |
+
async def shutdown_event():
|
| 27 |
+
await close_mongo_connection()
|
| 28 |
+
|
| 29 |
+
app.include_router(records_router)
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (185 Bytes). View file
|
|
|
app/models/__pycache__/monthly_record.cpython-311.pyc
ADDED
|
Binary file (3.23 kB). View file
|
|
|
app/models/monthly_record.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from typing import List, Optional
|
| 5 |
+
from pydantic import BaseModel, Field, field_validator
|
| 6 |
+
|
| 7 |
+
# ----- Pydantic Models -----
|
| 8 |
+
|
| 9 |
+
class ExpenseCategory(BaseModel):
|
| 10 |
+
category_id: str
|
| 11 |
+
name: str
|
| 12 |
+
amount: float = 0.0
|
| 13 |
+
color: str = "#cccccc"
|
| 14 |
+
|
| 15 |
+
class MonthlyRecord(BaseModel):
|
| 16 |
+
id: Optional[str] = Field(None, alias="_id")
|
| 17 |
+
month: str
|
| 18 |
+
year: int
|
| 19 |
+
month_key: str
|
| 20 |
+
salary: float
|
| 21 |
+
expenses: List[ExpenseCategory] = []
|
| 22 |
+
total_expenses: float = 0.0
|
| 23 |
+
remaining: float = 0.0
|
| 24 |
+
created_at: datetime
|
| 25 |
+
updated_at: datetime
|
| 26 |
+
|
| 27 |
+
model_config = {
|
| 28 |
+
"populate_by_name": True,
|
| 29 |
+
"json_encoders": {},
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
class MonthlyRecordCreate(BaseModel):
|
| 33 |
+
month: str
|
| 34 |
+
year: int
|
| 35 |
+
salary: float
|
| 36 |
+
expenses: List[ExpenseCategory] = []
|
| 37 |
+
|
| 38 |
+
class MonthlyRecordUpdate(BaseModel):
|
| 39 |
+
salary: Optional[float] = None
|
| 40 |
+
expenses: Optional[List[ExpenseCategory]] = None
|
| 41 |
+
|
| 42 |
+
# ----- Helpers -----
|
| 43 |
+
|
| 44 |
+
MONTHS = [
|
| 45 |
+
"January","February","March","April","May","June",
|
| 46 |
+
"July","August","September","October","November","December"
|
| 47 |
+
]
|
| 48 |
+
|
| 49 |
+
def normalize_month_name(name: str) -> str:
|
| 50 |
+
# Capitalize properly if user sends 'august'
|
| 51 |
+
return name.strip().capitalize()
|
| 52 |
+
|
| 53 |
+
def month_key_from(month: str, year: int) -> str:
|
| 54 |
+
month_norm = normalize_month_name(month)
|
| 55 |
+
idx = MONTHS.index(month_norm) + 1
|
| 56 |
+
return f"{year:04d}-{idx:02d}"
|
app/routes/__init__.py
ADDED
|
File without changes
|
app/routes/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (185 Bytes). View file
|
|
|
app/routes/__pycache__/records.cpython-311.pyc
ADDED
|
Binary file (4.19 kB). View file
|
|
|
app/routes/records.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Depends, Query
|
| 2 |
+
from typing import List, Dict, Any
|
| 3 |
+
|
| 4 |
+
from ..services.record_service import RecordService
|
| 5 |
+
from ..models.monthly_record import MonthlyRecordCreate, MonthlyRecordUpdate
|
| 6 |
+
|
| 7 |
+
router = APIRouter(prefix="/api/v1", tags=["records"])
|
| 8 |
+
|
| 9 |
+
def get_service() -> RecordService:
|
| 10 |
+
return RecordService()
|
| 11 |
+
|
| 12 |
+
@router.get("/records/{month_key}")
|
| 13 |
+
async def get_record(month_key: str, svc: RecordService = Depends(get_service)) -> Dict[str, Any]:
|
| 14 |
+
return await svc.get_by_month_key(month_key)
|
| 15 |
+
|
| 16 |
+
@router.get("/records")
|
| 17 |
+
async def list_records(
|
| 18 |
+
limit: int = Query(12, ge=1, le=200),
|
| 19 |
+
skip: int = Query(0, ge=0),
|
| 20 |
+
svc: RecordService = Depends(get_service),
|
| 21 |
+
) -> List[Dict[str, Any]]:
|
| 22 |
+
return await svc.list(limit=limit, skip=skip)
|
| 23 |
+
|
| 24 |
+
@router.post("/records", status_code=201)
|
| 25 |
+
async def create_record(payload: MonthlyRecordCreate, svc: RecordService = Depends(get_service)) -> Dict[str, Any]:
|
| 26 |
+
return await svc.create(payload)
|
| 27 |
+
|
| 28 |
+
@router.put("/records/{month_key}")
|
| 29 |
+
async def update_record(month_key: str, payload: MonthlyRecordUpdate, svc: RecordService = Depends(get_service)) -> Dict[str, Any]:
|
| 30 |
+
return await svc.update(month_key, payload)
|
| 31 |
+
|
| 32 |
+
@router.delete("/records/{month_key}", status_code=204)
|
| 33 |
+
async def delete_record(month_key: str, svc: RecordService = Depends(get_service)) -> None:
|
| 34 |
+
await svc.delete(month_key)
|
| 35 |
+
|
| 36 |
+
# Default categories
|
| 37 |
+
DEFAULT_CATEGORIES = [
|
| 38 |
+
{"category_id": "housing", "name": "Housing (Rent/Mortgage)", "amount": 0.0, "color": "#4299e1"},
|
| 39 |
+
{"category_id": "utilities", "name": "Utilities (Water/Power/Internet)", "amount": 0.0, "color": "#805ad5"},
|
| 40 |
+
{"category_id": "groceries", "name": "Food & Groceries", "amount": 0.0, "color": "#48bb78"},
|
| 41 |
+
{"category_id": "transport", "name": "Transport (Fuel/Taxi/Transit)", "amount": 0.0, "color": "#ed8936"},
|
| 42 |
+
{"category_id": "health", "name": "Healthcare/Pharmacy", "amount": 0.0, "color": "#f56565"},
|
| 43 |
+
{"category_id": "education", "name": "Education/Books", "amount": 0.0, "color": "#38b2ac"},
|
| 44 |
+
{"category_id": "family", "name": "Family/Children", "amount": 0.0, "color": "#d69e2e"},
|
| 45 |
+
{"category_id": "misc", "name": "Miscellaneous", "amount": 0.0, "color": "#a0aec0"},
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
@router.get("/categories/default")
|
| 49 |
+
async def default_categories() -> List[dict]:
|
| 50 |
+
return DEFAULT_CATEGORIES
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (187 Bytes). View file
|
|
|
app/services/__pycache__/record_service.cpython-311.pyc
ADDED
|
Binary file (8.13 kB). View file
|
|
|
app/services/record_service.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import datetime, timezone
|
| 2 |
+
from typing import Any, Dict, List, Optional
|
| 3 |
+
|
| 4 |
+
from fastapi import HTTPException, status
|
| 5 |
+
from bson import ObjectId
|
| 6 |
+
|
| 7 |
+
from ..database.connection import get_collection
|
| 8 |
+
from ..models.monthly_record import (
|
| 9 |
+
MonthlyRecord, MonthlyRecordCreate, MonthlyRecordUpdate,
|
| 10 |
+
ExpenseCategory, month_key_from, normalize_month_name
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
class RecordService:
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.col = get_collection()
|
| 16 |
+
|
| 17 |
+
async def ensure_indexes(self) -> None:
|
| 18 |
+
# Unique index on month_key for quick lookup
|
| 19 |
+
await self.col.create_index("month_key", unique=True)
|
| 20 |
+
|
| 21 |
+
def _calc_totals(self, salary: float, expenses: List[ExpenseCategory]) -> Dict[str, float]:
|
| 22 |
+
total_expenses = round(sum(e.amount for e in expenses), 2)
|
| 23 |
+
remaining = round(salary - total_expenses, 2)
|
| 24 |
+
return {"total_expenses": total_expenses, "remaining": remaining}
|
| 25 |
+
|
| 26 |
+
def _serialize(self, doc: Dict[str, Any]) -> Dict[str, Any]:
|
| 27 |
+
if not doc:
|
| 28 |
+
return doc
|
| 29 |
+
doc["_id"] = str(doc["_id"])
|
| 30 |
+
return doc
|
| 31 |
+
|
| 32 |
+
async def get_by_month_key(self, month_key: str) -> Dict[str, Any]:
|
| 33 |
+
doc = await self.col.find_one({"month_key": month_key})
|
| 34 |
+
if not doc:
|
| 35 |
+
raise HTTPException(status_code=404, detail="No record found for this month")
|
| 36 |
+
return self._serialize(doc)
|
| 37 |
+
|
| 38 |
+
async def list(self, limit: int = 12, skip: int = 0) -> List[Dict[str, Any]]:
|
| 39 |
+
cursor = self.col.find({}).sort("month_key", 1).skip(skip).limit(limit)
|
| 40 |
+
return [self._serialize(d) async for d in cursor]
|
| 41 |
+
|
| 42 |
+
async def create(self, payload: MonthlyRecordCreate) -> Dict[str, Any]:
|
| 43 |
+
month = normalize_month_name(payload.month)
|
| 44 |
+
month_key = month_key_from(month, payload.year)
|
| 45 |
+
now = datetime.now(timezone.utc)
|
| 46 |
+
|
| 47 |
+
totals = self._calc_totals(payload.salary, payload.expenses)
|
| 48 |
+
|
| 49 |
+
doc = {
|
| 50 |
+
"month": month,
|
| 51 |
+
"year": payload.year,
|
| 52 |
+
"month_key": month_key,
|
| 53 |
+
"salary": float(payload.salary),
|
| 54 |
+
"expenses": [e.model_dump() for e in payload.expenses],
|
| 55 |
+
"total_expenses": totals["total_expenses"],
|
| 56 |
+
"remaining": totals["remaining"],
|
| 57 |
+
"created_at": now,
|
| 58 |
+
"updated_at": now,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
result = await self.col.insert_one(doc)
|
| 63 |
+
except Exception as e:
|
| 64 |
+
# likely duplicate month_key
|
| 65 |
+
raise HTTPException(status_code=400, detail=str(e))
|
| 66 |
+
|
| 67 |
+
created = await self.col.find_one({"_id": result.inserted_id})
|
| 68 |
+
return self._serialize(created)
|
| 69 |
+
|
| 70 |
+
async def update(self, month_key: str, payload: MonthlyRecordUpdate) -> Dict[str, Any]:
|
| 71 |
+
existing = await self.col.find_one({"month_key": month_key})
|
| 72 |
+
if not existing:
|
| 73 |
+
raise HTTPException(status_code=404, detail="No record found for this month")
|
| 74 |
+
|
| 75 |
+
salary = payload.salary if payload.salary is not None else existing["salary"]
|
| 76 |
+
expenses = payload.expenses if payload.expenses is not None else existing["expenses"]
|
| 77 |
+
# If expenses came as pydantic models, convert to dicts
|
| 78 |
+
expenses_list = [e.model_dump() if hasattr(e, "model_dump") else e for e in expenses]
|
| 79 |
+
|
| 80 |
+
totals = self._calc_totals(salary, [ExpenseCategory(**e) for e in expenses_list])
|
| 81 |
+
|
| 82 |
+
update_doc = {
|
| 83 |
+
"$set": {
|
| 84 |
+
"salary": float(salary),
|
| 85 |
+
"expenses": expenses_list,
|
| 86 |
+
"total_expenses": totals["total_expenses"],
|
| 87 |
+
"remaining": totals["remaining"],
|
| 88 |
+
"updated_at": datetime.now(timezone.utc),
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
await self.col.update_one({"month_key": month_key}, update_doc)
|
| 93 |
+
updated = await self.col.find_one({"month_key": month_key})
|
| 94 |
+
return self._serialize(updated)
|
| 95 |
+
|
| 96 |
+
async def delete(self, month_key: str) -> None:
|
| 97 |
+
result = await self.col.delete_one({"month_key": month_key})
|
| 98 |
+
if result.deleted_count == 0:
|
| 99 |
+
raise HTTPException(status_code=404, detail="No record found for this month")
|