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")
|