MatrixIA commited on
Commit
fade1d6
·
verified ·
1 Parent(s): 97f4fc4

Upload 24 files

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